add new ephemeral mode audit logging endpoints (#37715)

* add new ephemeral mode audit logging endpoints

* add license requirement and server timestamps to ephemeral mode audit endpoints

* adds openapi specs for new ephemeral mode audit api endpoints

* support for providing errors when adding audit logs related to ephemeral mode events

* address PR comments

* change spec to reflect on nullable fields

* adds missing context argument
This commit is contained in:
Carlos Garcia
2026-09-15 09:05:04 +02:00
committed by GitHub
parent 5b866b8c22
commit 46bfb71725
13 changed files with 1085 additions and 0 deletions
+1
View File
@@ -62,6 +62,7 @@ build-v4: node_modules playbooks
@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)
@cat $(V4_SRC)/ephemeral_mode.yaml >> $(V4_YAML)
@cat $(V4_SRC)/access_control.yaml >> $(V4_YAML)
@cat $(V4_SRC)/content_flagging.yaml >> $(V4_YAML)
@cat $(V4_SRC)/delivery_tracking.yaml >> $(V4_YAML)
+206
View File
@@ -0,0 +1,206 @@
/api/v4/ephemeral_mode/cleanup:
post:
tags:
- ephemeral mode
summary: Log an ephemeral mode cleanup run
description: >
Records the outcome of a client-side ephemeral mode auto cache cleanup run as an
audit event.
__Minimum server version__: 11.10
##### Permissions
Must be logged in.
##### License
Requires an Enterprise Advanced license.
operationId: LogCleanup
requestBody:
content:
application/json:
schema:
type: object
properties:
posts_deleted:
description: >
The number of posts deleted by the cleanup run. Required unless
error_reason is provided, since a successful run with no counts
records no evidence of what was deleted. If omitted, no posts_deleted
value is recorded on the audit event.
type: integer
format: int64
nullable: true
playbook_runs_deleted:
description: >
The number of playbook runs deleted by the cleanup run. Required
unless error_reason is provided. If omitted, no playbook_runs_deleted
value is recorded on the audit event.
type: integer
format: int64
nullable: true
cleanup_at:
description: >
The time in milliseconds the cleanup run completed. Always optional;
it does not satisfy the requirement for the deleted record counts. If
omitted, no cleanup_at value is recorded on the audit event; the
server-observed time is always recorded separately as server_ts.
type: integer
format: int64
nullable: true
error_reason:
description: >
A reason describing why the cleanup run failed. Its presence alone
marks the audit event as a failure, so clients must omit this field
entirely (or send null) when the run succeeded; sending an empty
string records a failure with no description attached. Reasons
longer than 400 characters are truncated to 400 with a trailing
ellipsis rather than rejected.
type: string
nullable: true
required: true
responses:
"200":
description: Cleanup logged successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/ephemeral_mode/purge:
post:
tags:
- ephemeral mode
summary: Log an ephemeral mode offline purge
description: >
Records the outcome of a client-side ephemeral mode offline purge as
an audit event.
__Minimum server version__: 11.10
##### Permissions
Must be logged in.
##### License
Requires an Enterprise Advanced license.
operationId: LogOfflinePurge
requestBody:
content:
application/json:
schema:
type: object
properties:
offline_time_minutes:
description: >
The number of minutes the client was offline before the purge.
Required unless error_reason is provided, since a successful purge
with no offline time records no evidence of why it ran. If omitted,
no offline_time_minutes value is recorded on the audit event.
type: integer
format: int64
nullable: true
purge_at:
description: >
The time in milliseconds the purge completed. Always optional; it
does not satisfy the requirement for offline_time_minutes. If
omitted, no purge_at value is recorded on the audit event; the
server-observed time is always recorded separately as server_ts.
type: integer
format: int64
nullable: true
error_reason:
description: >
A reason describing why the offline purge failed. Its presence alone
marks the audit event as a failure, so clients must omit this field
entirely (or send null) when the purge succeeded; sending an empty
string records a failure with no description attached. Reasons
longer than 400 characters are truncated to 400 with a trailing
ellipsis rather than rejected.
type: string
nullable: true
required: true
responses:
"200":
description: Offline purge logged successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/ephemeral_mode/wipe:
post:
tags:
- ephemeral mode
summary: Log an ephemeral mode session wipe
description: >
Records a client-side ephemeral mode wipe confirming that a session
revoked by the server has completed its local wipe. Sent without a
session, since the session being confirmed is already revoked by the
time the client can report it.
__Minimum server version__: 11.10
##### License
Requires an Enterprise Advanced license.
operationId: LogSessionWipe
requestBody:
content:
application/json:
schema:
type: object
properties:
signature:
description: >
The `signature` field of the session wipe push notification that
triggered this wipe, echoed back verbatim. The server verifies it and
takes the actor, device and ack id of the audit event from its claims.
Always required, including on failure reports: this endpoint accepts
unauthenticated requests, so an unattributable audit record is not
accepted.
type: string
wipe_at:
description: >
The time in milliseconds the wipe completed. Optional. If omitted, no
wipe_at value is recorded on the audit event; the server-observed time
is always recorded separately as server_ts.
type: integer
format: int64
nullable: true
error_reason:
description: >
A reason describing why the session wipe failed. Its presence alone
marks the audit event as a failure, so clients must omit this field
entirely (or send null) when the wipe succeeded; sending an empty
string records a failure with no description attached. Reasons
longer than 400 characters are truncated to 400 with a trailing
ellipsis rather than rejected.
type: string
nullable: true
required:
- signature
required: true
responses:
"200":
description: Session wipe logged successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"400":
$ref: "#/components/responses/BadRequest"
"501":
$ref: "#/components/responses/NotImplemented"
+2
View File
@@ -471,6 +471,8 @@ tags:
description: Endpoints related to metrics, including the Client Performance Monitoring feature.
- name: audit_logs
description: Endpoints for managing audit log certificates and configuration.
- name: ephemeral mode
description: Endpoints for logging client-side ephemeral mode audit events.
- name: recaps
description: Endpoints for creating and managing AI-powered channel recaps that summarize unread messages.
- name: scheduled recaps
+4
View File
@@ -98,6 +98,8 @@ type Routes struct {
DataRetention *mux.Router // 'api/v4/data_retention'
EphemeralMode *mux.Router // 'api/v4/ephemeral_mode'
Brand *mux.Router // 'api/v4/brand'
System *mux.Router // 'api/v4/system'
@@ -284,6 +286,7 @@ func Init(srv *app.Server) (*API, error) {
api.BaseRoutes.ScheduledRecap = api.BaseRoutes.ScheduledRecaps.PathPrefix("/{scheduled_recap_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter()
api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter()
api.BaseRoutes.EphemeralMode = api.BaseRoutes.APIRoot.PathPrefix("/ephemeral_mode").Subrouter()
api.BaseRoutes.Emojis = api.BaseRoutes.APIRoot.PathPrefix("/emoji").Subrouter()
api.BaseRoutes.Emoji = api.BaseRoutes.APIRoot.PathPrefix("/emoji/{emoji_id:[A-Za-z0-9]+}").Subrouter()
@@ -374,6 +377,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitLdap()
api.InitElasticsearch()
api.InitDataRetention()
api.InitEphemeralMode()
api.InitBrand()
api.InitJob()
api.InitRecap()
+161
View File
@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
// errorReasonMaxRunes matches the bound postLog applies to client-supplied log messages.
const errorReasonMaxRunes = 400
// truncateErrorReason marks a cut reason so it stays distinguishable from a complete one.
func truncateErrorReason(reason string) string {
const ellipsis = "..."
runes := []rune(reason)
if len(runes) <= errorReasonMaxRunes {
return reason
}
return string(runes[:errorReasonMaxRunes-len(ellipsis)]) + ellipsis
}
func (api *API) InitEphemeralMode() {
api.BaseRoutes.EphemeralMode.Handle("/cleanup", api.APISessionRequired(logCleanup)).Methods(http.MethodPost)
api.BaseRoutes.EphemeralMode.Handle("/purge", api.APISessionRequired(logOfflinePurge)).Methods(http.MethodPost)
api.BaseRoutes.EphemeralMode.Handle("/wipe", api.APIHandler(logSessionWipe)).Methods(http.MethodPost)
}
func logCleanup(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("logCleanup", "license_error.feature_unavailable.specific", map[string]any{"Feature": "Ephemeral Mode"}, "", http.StatusNotImplemented)
return
}
var report model.CleanupReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
c.SetInvalidParamWithErr("cleanup_report", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAutoCacheCleanupRun, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
// if no deleted count reported and it is not an error, the request is considered invalid
if report.ErrorReason == nil {
if report.PostsDeleted == nil {
c.SetInvalidParam("posts_deleted")
return
}
if report.PlaybookRunsDeleted == nil {
c.SetInvalidParam("playbook_runs_deleted")
return
}
}
if report.PostsDeleted != nil {
model.AddEventParameterToAuditRec(auditRec, "posts_deleted", *report.PostsDeleted)
}
if report.PlaybookRunsDeleted != nil {
model.AddEventParameterToAuditRec(auditRec, "playbook_runs_deleted", *report.PlaybookRunsDeleted)
}
if report.CleanupAt != nil {
model.AddEventParameterToAuditRec(auditRec, "cleanup_at", *report.CleanupAt)
}
model.AddEventParameterToAuditRec(auditRec, "server_ts", model.GetMillis())
if report.ErrorReason != nil {
auditRec.AddErrorDesc(truncateErrorReason(*report.ErrorReason))
auditRec.Fail()
} else {
auditRec.Success()
}
ReturnStatusOK(w)
}
func logOfflinePurge(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("logOfflinePurge", "license_error.feature_unavailable.specific", map[string]any{"Feature": "Ephemeral Mode"}, "", http.StatusNotImplemented)
return
}
var report model.OfflinePurgeReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
c.SetInvalidParamWithErr("offline_purge_report", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventOfflinePurge, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
// a successful purge with no offline time records no evidence of why it ran
if report.ErrorReason == nil && report.OfflineTimeMinutes == nil {
c.SetInvalidParam("offline_time_minutes")
return
}
if report.OfflineTimeMinutes != nil {
model.AddEventParameterToAuditRec(auditRec, "offline_time_minutes", *report.OfflineTimeMinutes)
}
if report.PurgeAt != nil {
model.AddEventParameterToAuditRec(auditRec, "purge_at", *report.PurgeAt)
}
model.AddEventParameterToAuditRec(auditRec, "server_ts", model.GetMillis())
if report.ErrorReason != nil {
auditRec.AddErrorDesc(truncateErrorReason(*report.ErrorReason))
auditRec.Fail()
} else {
auditRec.Success()
}
ReturnStatusOK(w)
}
// logSessionWipe is called by a client confirming a local wipe after its session was revoked, so
// it runs without a session and the actor is taken from the signature the wipe push carried.
func logSessionWipe(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("logSessionWipe", "license_error.feature_unavailable.specific", map[string]any{"Feature": "Ephemeral Mode"}, "", http.StatusNotImplemented)
return
}
var report model.SessionWipeReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
c.SetInvalidParamWithErr("session_wipe_report", err)
return
}
// verified before the audit record exists so an unverified caller cannot append one at all
claims, err := c.App.VerifyWipeSignature(report.Signature)
if err != nil {
c.SetInvalidParamWithErr("signature", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventSessionWipe, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.Actor.UserId = claims.UserId
model.AddEventParameterToAuditRec(auditRec, "device_id", model.RedactDeviceId(claims.DeviceId))
model.AddEventParameterToAuditRec(auditRec, "ack_id", claims.AckId)
if report.WipeAt != nil {
model.AddEventParameterToAuditRec(auditRec, "wipe_at", *report.WipeAt)
}
model.AddEventParameterToAuditRec(auditRec, "server_ts", model.GetMillis())
if report.ErrorReason != nil {
auditRec.AddErrorDesc(truncateErrorReason(*report.ErrorReason))
auditRec.Fail()
} else {
auditRec.Success()
}
ReturnStatusOK(w)
}
+486
View File
@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"io"
"net/http"
"os"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
const (
wipeSignatureAckId = "ackid"
wipeSignatureDeviceId = "testdevice"
)
// signWipePush mints the signature a session wipe push carries for the given user, which the
// client echoes back when confirming its wipe.
func signWipePush(t *testing.T, th *TestHelper, userID string) string {
t.Helper()
signature, err := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"ack_id": wipeSignatureAckId,
"device_id": wipeSignatureDeviceId,
"user_id": userID,
}).SignedString(th.App.AsymmetricSigningKey())
require.NoError(t, err)
return signature
}
// setupEphemeralModeAuditTest starts a TestHelper with a file-based audit target so tests can
// read back the audit entries the ephemeral mode endpoints log.
func setupEphemeralModeAuditTest(t *testing.T) (*TestHelper, *os.File) {
logFile, err := os.CreateTemp("", "ephemeral_mode_audit.log")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, logFile.Close())
require.NoError(t, os.Remove(logFile.Name()))
})
th := SetupWithServerOptionsAndConfig(t, nil, func(cfg *model.Config) {
cfg.ExperimentalAuditSettings.FileEnabled = new(true)
cfg.ExperimentalAuditSettings.FileName = new(logFile.Name())
}).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
t.Cleanup(func() { th.RemoveLicense(t) })
return th, logFile
}
func readAuditEntry(t *testing.T, th *TestHelper, logFile *os.File, eventName, userID string) *AuditEntry {
require.NoError(t, th.Server.Audit.Flush())
require.NoError(t, logFile.Sync())
data, err := io.ReadAll(logFile)
require.NoError(t, err)
entry := FindAuditEntry(string(data), eventName, userID)
require.NotNil(t, entry, "should find a %s audit entry for user %s", eventName, userID)
return entry
}
func TestLogCleanup(t *testing.T) {
mainHelper.Parallel(t)
t.Run("logs success when deleted record counts and cleanup date are all provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
PostsDeleted: model.NewPointer(int64(10)),
PlaybookRunsDeleted: model.NewPointer(int64(2)),
CleanupAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 10, entry.Parameters["posts_deleted"])
require.EqualValues(t, 2, entry.Parameters["playbook_runs_deleted"])
require.Contains(t, entry.Parameters, "cleanup_at")
require.Contains(t, entry.Parameters, "server_ts")
})
t.Run("succeeds when cleanup date is omitted but deleted record counts are provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
PostsDeleted: model.NewPointer(int64(5)),
PlaybookRunsDeleted: model.NewPointer(int64(1)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 5, entry.Parameters["posts_deleted"])
require.EqualValues(t, 1, entry.Parameters["playbook_runs_deleted"])
require.NotContains(t, entry.Parameters, "cleanup_at")
})
t.Run("accepts a failure report with no deleted record counts and records no counts for it", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
CleanupAt: model.NewPointer(model.GetMillis()),
ErrorReason: model.NewPointer("disk full during cleanup"),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
require.NotContains(t, entry.Parameters, "posts_deleted")
require.NotContains(t, entry.Parameters, "playbook_runs_deleted")
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "disk full during cleanup", errData["description"])
})
t.Run("logs failure without a description when the client reports an empty error reason", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.CleanupReport{
ErrorReason: model.NewPointer(""),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventAutoCacheCleanupRun, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.NotContains(t, errData, "description")
})
t.Run("rejects a success report that provides only the cleanup date", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.CleanupReport{
CleanupAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects a success report that provides only the deleted post count", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.CleanupReport{
PostsDeleted: model.NewPointer(int64(3)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects request when license does not support ephemeral mode", func(t *testing.T) {
th := Setup(t).InitBasic(t)
body := mustMarshal(t, model.CleanupReport{
CleanupAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/cleanup", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
}
func TestLogOfflinePurge(t *testing.T) {
mainHelper.Parallel(t)
t.Run("logs success when offline time and purge date are both provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(90)),
PurgeAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 90, entry.Parameters["offline_time_minutes"])
require.Contains(t, entry.Parameters, "purge_at")
require.Contains(t, entry.Parameters, "server_ts")
})
t.Run("succeeds when purge date is omitted but offline time is provided", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(45)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.EqualValues(t, 45, entry.Parameters["offline_time_minutes"])
require.NotContains(t, entry.Parameters, "purge_at")
})
t.Run("logs failure with the reported reason when the client reports the offline purge failed", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(90)),
ErrorReason: model.NewPointer("could not reach storage"),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "could not reach storage", errData["description"])
})
t.Run("accepts a failure report with no offline time and records no offline time for it", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, model.OfflinePurgeReport{
ErrorReason: model.NewPointer("could not read the last connected timestamp"),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventOfflinePurge, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
require.NotContains(t, entry.Parameters, "offline_time_minutes")
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "could not read the last connected timestamp", errData["description"])
})
t.Run("rejects request missing offline time even when purge date is provided", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.OfflinePurgeReport{
PurgeAt: model.NewPointer(model.GetMillis()),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects request missing offline time and purge date", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
body := mustMarshal(t, model.OfflinePurgeReport{})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("rejects request when license does not support ephemeral mode", func(t *testing.T) {
th := Setup(t).InitBasic(t)
body := mustMarshal(t, model.OfflinePurgeReport{
OfflineTimeMinutes: model.NewPointer(int64(45)),
})
resp, err := th.Client.DoAPIPost(context.Background(), "/ephemeral_mode/purge", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
}
func TestLogSessionWipe(t *testing.T) {
mainHelper.Parallel(t)
// The wipe confirmation is sent by a client whose session was already revoked server-side,
// so it must succeed without any authentication. The signature the wipe push carried is
// what attributes the record.
t.Run("logs success without a session and takes the actor from the signature", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
WipeAt: model.NewPointer(model.GetMillis()),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventSessionWipe, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.Equal(t, model.RedactDeviceId(wipeSignatureDeviceId), entry.Parameters["device_id"])
require.Equal(t, wipeSignatureAckId, entry.Parameters["ack_id"])
require.Contains(t, entry.Parameters, "wipe_at")
require.Contains(t, entry.Parameters, "server_ts")
})
t.Run("succeeds when the wipe date is omitted", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventSessionWipe, th.BasicUser.Id)
require.Equal(t, model.AuditStatusSuccess, entry.Status)
require.NotContains(t, entry.Parameters, "wipe_at")
})
t.Run("logs failure with the reported reason when the client reports the session wipe failed", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
ErrorReason: model.NewPointer("local storage wipe threw an exception"),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, model.AuditEventSessionWipe, th.BasicUser.Id)
require.Equal(t, model.AuditStatusFail, entry.Status)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, "local storage wipe threw an exception", errData["description"])
})
// An error reason must not waive the signature requirement: this endpoint is
// unauthenticated, so that would let anyone append unattributable audit records.
t.Run("rejects a failure report that omits the signature", func(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
ErrorReason: model.NewPointer("local storage was already unreadable"),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
// Rejecting the request is not enough on an unauthenticated endpoint: without a verified
// signature there is no actor, so the attempt must leave no sessionWipe record behind at all.
t.Run("rejects a report whose signature does not verify and records nothing for it", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id) + "tampered",
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.NoError(t, th.Server.Audit.Flush())
data, err := io.ReadAll(logFile)
require.NoError(t, err)
require.Nil(t, FindAuditEntry(string(data), model.AuditEventSessionWipe, ""))
})
t.Run("rejects request when license does not support ephemeral mode", func(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.CreateClient()
body := mustMarshal(t, model.SessionWipeReport{
Signature: signWipePush(t, th, th.BasicUser.Id),
})
resp, err := client.DoAPIPost(context.Background(), "/ephemeral_mode/wipe", string(body))
require.Error(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
}
// An over-long reason is truncated rather than rejected, so a client that reports a failure
// verbosely still gets its audit record. The ellipsis keeps a cut reason distinguishable from
// a complete one when the record is read back as evidence.
func TestEphemeralModeTruncatesLongErrorReasons(t *testing.T) {
mainHelper.Parallel(t)
longReason := strings.Repeat("test", errorReasonMaxRunes)
testCases := []struct {
name string
path string
event string
client func(th *TestHelper) *model.Client4
body func(t *testing.T, th *TestHelper) any
}{
{
name: "cleanup",
path: "/ephemeral_mode/cleanup",
event: model.AuditEventAutoCacheCleanupRun,
client: func(th *TestHelper) *model.Client4 { return th.Client },
body: func(_ *testing.T, _ *TestHelper) any {
return model.CleanupReport{ErrorReason: model.NewPointer(longReason)}
},
},
{
name: "offline purge",
path: "/ephemeral_mode/purge",
event: model.AuditEventOfflinePurge,
client: func(th *TestHelper) *model.Client4 { return th.Client },
body: func(_ *testing.T, _ *TestHelper) any {
return model.OfflinePurgeReport{ErrorReason: model.NewPointer(longReason)}
},
},
{
name: "session wipe",
path: "/ephemeral_mode/wipe",
event: model.AuditEventSessionWipe,
client: func(th *TestHelper) *model.Client4 { return th.CreateClient() },
body: func(t *testing.T, th *TestHelper) any {
return model.SessionWipeReport{Signature: signWipePush(t, th, th.BasicUser.Id), ErrorReason: model.NewPointer(longReason)}
},
},
}
for _, tc := range testCases {
t.Run(tc.name+" records the error reason cut to the maximum length and marked as cut", func(t *testing.T) {
th, logFile := setupEphemeralModeAuditTest(t)
body := mustMarshal(t, tc.body(t, th))
resp, err := tc.client(th).DoAPIPost(context.Background(), tc.path, string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
entry := readAuditEntry(t, th, logFile, tc.event, th.BasicUser.Id)
errData, ok := entry.Raw["error"].(map[string]any)
require.True(t, ok)
require.Equal(t, longReason[:errorReasonMaxRunes-3]+"...", errData["description"])
})
}
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"github.com/mattermost/mattermost/server/public/model"
)
// WipeSignatureClaims are the verified contents of a session wipe push signature.
type WipeSignatureClaims struct {
UserId string
DeviceId string
AckId string
}
// VerifyWipeSignature checks a signature a client took from the session wipe push it received.
// Only sendMobileWipeSignal sets the user id claim, so requiring it stops a signature lifted
// from an ordinary push notification being replayed as proof of a wipe.
func (a *App) VerifyWipeSignature(signature string) (*WipeSignatureClaims, error) {
key := a.AsymmetricSigningKey()
if key == nil {
return nil, errors.New("no asymmetric signing key is available")
}
var claims pushJWTClaims
if _, err := jwt.ParseWithClaims(signature, &claims, func(*jwt.Token) (any, error) {
return &key.PublicKey, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodES256.Alg()})); err != nil {
return nil, err
}
if !model.IsValidId(claims.UserId) {
return nil, errors.New("wipe signature does not carry a valid user id claim")
}
return &WipeSignatureClaims{
UserId: claims.UserId,
DeviceId: claims.DeviceId,
AckId: claims.AckId,
}, nil
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestVerifyWipeSignature(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
signWith := func(t *testing.T, key *ecdsa.PrivateKey, claims jwt.Claims) string {
t.Helper()
signature, err := jwt.NewWithClaims(jwt.SigningMethodES256, claims).SignedString(key)
require.NoError(t, err)
return signature
}
t.Run("returns the user, device and ack a wipe push was signed for", func(t *testing.T) {
signature := signWith(t, th.App.AsymmetricSigningKey(), pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: th.BasicUser.Id,
})
claims, err := th.App.VerifyWipeSignature(signature)
require.NoError(t, err)
require.Equal(t, &WipeSignatureClaims{
UserId: th.BasicUser.Id,
DeviceId: "testdevice",
AckId: "ackid",
}, claims)
})
t.Run("rejects a signature carrying a malformed user id", func(t *testing.T) {
signature := signWith(t, th.App.AsymmetricSigningKey(), pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: "not-a-valid-user-id",
})
_, err := th.App.VerifyWipeSignature(signature)
require.Error(t, err)
})
t.Run("rejects a signature signed by another server's key", func(t *testing.T) {
otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
signature := signWith(t, otherKey, pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: th.BasicUser.Id,
})
_, err = th.App.VerifyWipeSignature(signature)
require.Error(t, err)
})
t.Run("rejects an unsigned token using the none algorithm", func(t *testing.T) {
signature, err := jwt.NewWithClaims(jwt.SigningMethodNone, pushJWTClaims{
AckId: "ackid",
DeviceId: "testdevice",
UserId: th.BasicUser.Id,
}).SignedString(jwt.UnsafeAllowNoneSignatureType)
require.NoError(t, err)
_, err = th.App.VerifyWipeSignature(signature)
require.Error(t, err)
})
t.Run("rejects an empty signature", func(t *testing.T) {
_, err := th.App.VerifyWipeSignature("")
require.Error(t, err)
})
}
// VerifyWipeSignature trusts the user id claim because only sendMobileWipeSignal sets it. If the
// regular notification path ever starts setting it too, every push signature becomes a valid
// wipe proof and this fails.
func TestRegularPushSignatureIsNotAcceptedAsAWipeSignature(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
handler := &testPushNotificationHandler{t: t, behavior: "simple"}
pushServer := httptest.NewServer(http.HandlerFunc(handler.handleReq))
t.Cleanup(pushServer.Close)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.SendPushNotifications = true
*cfg.EmailSettings.PushNotificationServer = pushServer.URL
})
_, appErr := th.App.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
DeviceId: model.PushNotifyAppleReactNative + ":testdevice",
ExpiresAt: model.GetMillis() + 100000,
})
require.Nil(t, appErr)
appErr = th.App.sendPushNotificationToAllSessions(th.Context, &model.PushNotification{Type: model.PushTypeMessage}, th.BasicUser.Id, "")
require.Nil(t, appErr)
require.Eventually(t, func() bool {
return len(handler.notifications()) == 1
}, 2*time.Second, 10*time.Millisecond)
_, err := th.App.VerifyWipeSignature(handler.notifications()[0].Signature)
require.Error(t, err)
}
+2
View File
@@ -29,6 +29,8 @@ type notificationType string
type pushJWTClaims struct {
AckId string `json:"ack_id"`
DeviceId string `json:"device_id"`
// set only for session wipe pushes; VerifyWipeSignature rejects signatures without it
UserId string `json:"user_id,omitempty"`
jwt.RegisteredClaims
}
+5
View File
@@ -10,6 +10,7 @@ import (
"math"
"net/http"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/mattermost/mattermost/server/public/model"
@@ -217,6 +218,10 @@ func (a *App) sendMobileWipeSignal(rctx request.CTX, sessions ...*model.Session)
signature, signErr := jwt.NewWithClaims(jwt.SigningMethodES256, pushJWTClaims{
AckId: msg.AckId,
DeviceId: msg.DeviceId,
UserId: session.UserId,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}).SignedString(a.AsymmetricSigningKey())
if signErr != nil {
rctx.Logger().Warn("Failed to sign session wipe push", mlog.String("session_id", session.Id), mlog.Err(signErr))
+15
View File
@@ -588,6 +588,21 @@ func TestSendMobileWipeSignal(t *testing.T) {
require.Empty(t, n.Message)
})
// The client echoes this signature back to the session wipe audit endpoint, which has no
// session to authenticate against by then.
t.Run("wipe push is signed for the revoked session's user and device", func(t *testing.T) {
handler := setupPushServer(t, th)
session := &model.Session{UserId: th.BasicUser.Id, DeviceId: "android:testdevice", ExpiresAt: model.GetMillis() + 100000}
th.App.sendMobileWipeSignal(th.Context, session)
require.Equal(t, 1, handler.numReqs())
claims, err := th.App.VerifyWipeSignature(handler.notifications()[0].Signature)
require.NoError(t, err)
require.Equal(t, th.BasicUser.Id, claims.UserId)
require.Equal(t, "testdevice", claims.DeviceId)
})
t.Run("continues to remaining sessions when proxy returns PushStatusRemove", func(t *testing.T) {
handler := &testPushNotificationHandler{t: t} // alternates: req 1 → REMOVE, req 2 → OK
pushServer := httptest.NewServer(http.HandlerFunc(handler.handleReq))
+7
View File
@@ -192,6 +192,13 @@ const (
AuditEventRemoveTeamsFromPolicy = "removeTeamsFromPolicy" // remove teams from data retention policy
)
// Ephemeral Mode
const (
AuditEventAutoCacheCleanupRun = "autoCacheCleanupRun" // automatic cache cleanup run
AuditEventOfflinePurge = "offlinePurge" // offline purge
AuditEventSessionWipe = "sessionWipe" // session wipe
)
// Emojis
const (
AuditEventCreateEmoji = "createEmoji" // create emoji
+28
View File
@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// CleanupReport describes the outcome of a client-side ephemeral mode cleanup run.
type CleanupReport struct {
PostsDeleted *int64 `json:"posts_deleted"`
PlaybookRunsDeleted *int64 `json:"playbook_runs_deleted"`
CleanupAt *int64 `json:"cleanup_at"`
ErrorReason *string `json:"error_reason"`
}
// OfflinePurgeReport describes the outcome of a client-side ephemeral mode offline purge.
type OfflinePurgeReport struct {
OfflineTimeMinutes *int64 `json:"offline_time_minutes"`
PurgeAt *int64 `json:"purge_at"`
ErrorReason *string `json:"error_reason"`
}
// SessionWipeReport confirms a client-side ephemeral mode wipe triggered by a session revocation.
// Signature is the one the wipe push carried, echoed back to attribute the report: the session it
// refers to is already revoked by the time it is sent.
type SessionWipeReport struct {
WipeAt *int64 `json:"wipe_at"`
ErrorReason *string `json:"error_reason"`
Signature string `json:"signature"`
}