mirror of
https://github.com/mattermost/mattermost.git
synced 2026-10-13 09:27:38 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user