Files
team/server/channels/app/slashcommands/command_mobile_logs.go
602b8ed226 [MM-67880] Add /mobile-logs slash command (#35658)
* [MM-67880] Add /mobile-logs slash command with E2E tests

Add a new /mobile-logs slash command that allows users to manage the
attach_app_logs preference for themselves or other users (admin-only).
Includes unit tests for all code paths and Playwright E2E tests covering
self-management, admin cross-user management, permission denial, and
error handling.

* Fix i18n error key suffixes and format E2E tests

Rename error i18n keys to use .app_error suffix matching upstream
convention (no_permission, update_error, user_not_found). Run prettier
on the E2E test file to fix formatting.

* Fix E2E test type error: use getUserPreferences instead of getMyPreferences

The getAttachLogsPreference helper was using getMyPreferences() which returns
PreferenceType (not an array), causing TS2345 errors. Switch to
getUserPreferences(userId) which returns the expected array type.

* [MM-67880] Move unreachable usage fallback into switch default case

The return after the switch was unreachable because action is validated
earlier to be "on", "off", or "status". Move it into an explicit default
case with a defensive comment.

* allow up to 2 arguments

* Add audit logging for mobile logs slash command actions

Implement a new function to log audit records when users enable or disable the attach_app_logs preference via the /mobile-logs command. This includes capturing relevant metadata such as user IDs, session information, and the action taken. The logging occurs in both the enable and disable command paths, enhancing traceability and accountability for user preference changes.

* Enhance mobile logs command to handle cross-user permission checks

Add a new response function for cases where a regular user attempts to access mobile log settings for another user, ensuring they receive a neutral error message instead of specific user information. Update the command logic to incorporate this response for both nonexistent users and deactivated accounts. Additionally, modify related tests and internationalization keys to reflect these changes, improving security and user experience.

* Update E2E test for /mobile-logs command to verify permission denial for nonexistent users

Enhance the existing E2E test for the /mobile-logs command by adding assertions to check that users receive a permission denial message when attempting to change mobile log settings for a nonexistent user. This improves test coverage and ensures proper error handling in the application.

* update i18n strings

---------

Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-04-20 12:46:36 +02:00

247 lines
8.7 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type MobileLogsProvider struct {
}
const (
CmdMobileLogs = "mobile-logs"
)
func init() {
app.RegisterCommandProvider(&MobileLogsProvider{})
}
func mobileLogsCrossUserUnavailableResponse(args *model.CommandArgs) *model.CommandResponse {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.cross_user_unavailable.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
func logMobileLogsAttachAppLogsAudit(a *app.App, rctx request.CTX, actorUserID, targetUserID, value string) {
rec := &model.AuditRecord{
EventName: model.AuditEventUpdatePreferences,
Status: model.AuditStatusSuccess,
Actor: model.AuditEventActor{
UserId: actorUserID,
SessionId: rctx.Session().Id,
Client: rctx.UserAgent(),
IpAddress: rctx.IPAddress(),
XForwardedFor: rctx.XForwardedFor(),
},
Meta: map[string]any{
model.AuditKeyAPIPath: rctx.Path(),
model.AuditKeyClusterID: a.GetClusterId(),
},
EventData: model.AuditEventData{
Parameters: map[string]any{
"source": "slash_command/" + CmdMobileLogs,
"target_user_id": targetUserID,
"preference_category": model.PreferenceCategoryAdvancedSettings,
"preference_name": model.PreferenceNameAttachAppLogs,
"value": value,
},
PriorState: map[string]any{},
ResultState: map[string]any{},
ObjectType: "user_preference",
},
}
a.LogAuditRecWithLevel(rctx, rec, app.LevelAPI, nil)
}
func (*MobileLogsProvider) GetTrigger() string {
return CmdMobileLogs
}
func (*MobileLogsProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdMobileLogs,
AutoComplete: true,
AutoCompleteDesc: T("api.command_mobile_logs.desc"),
AutoCompleteHint: T("api.command_mobile_logs.hint"),
DisplayName: T("api.command_mobile_logs.name"),
}
}
func (*MobileLogsProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
fields := strings.Fields(message)
if len(fields) == 0 || len(fields) > 2 {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.usage"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
action := strings.ToLower(fields[0])
if action != "on" && action != "off" && action != "status" {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.usage"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
targetUserID := args.UserId
targetDisplayName := args.T("api.command_mobile_logs.you")
if len(fields) > 1 {
username := strings.TrimPrefix(fields[1], "@")
username = strings.ToLower(username)
if !model.IsValidUsername(username) {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
caller, appErr := a.GetUser(args.UserId)
if appErr != nil {
rctx.Logger().Error("Failed to get caller for mobile-logs command", mlog.String("user_id", args.UserId), mlog.Err(appErr))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.update_error.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
isSelf := username == strings.ToLower(caller.Username)
if isSelf {
targetUserID = caller.Id
targetDisplayName = "@" + caller.Username
} else {
// Cross-user: callers without system admin get one neutral outcome for any failure
// (unknown user, deactivated, disallowed target, or missing role) to avoid username
// enumeration. System admins still get explicit not-found messages for support workflows.
if !a.HasPermissionTo(args.UserId, model.PermissionManageSystem) && !a.HasPermissionTo(args.UserId, model.PermissionEditOtherUsers) {
return mobileLogsCrossUserUnavailableResponse(args)
}
callerHasManageSystem := a.HasPermissionTo(args.UserId, model.PermissionManageSystem)
targetUser, lookupErr := a.GetUserByUsername(username)
if lookupErr != nil {
if lookupErr.StatusCode == http.StatusNotFound {
if callerHasManageSystem {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return mobileLogsCrossUserUnavailableResponse(args)
}
rctx.Logger().Error("Failed to get user by username", mlog.String("username", username), mlog.Err(lookupErr))
if callerHasManageSystem {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return mobileLogsCrossUserUnavailableResponse(args)
}
if targetUser.DeleteAt != 0 {
if callerHasManageSystem {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return mobileLogsCrossUserUnavailableResponse(args)
}
targetUserID = targetUser.Id
targetDisplayName = "@" + targetUser.Username
if !callerHasManageSystem && targetUser.IsSystemAdmin() {
return mobileLogsCrossUserUnavailableResponse(args)
}
}
}
switch action {
case "on":
prefs := model.Preferences{
{
UserId: targetUserID,
Category: model.PreferenceCategoryAdvancedSettings,
Name: model.PreferenceNameAttachAppLogs,
Value: "true",
},
}
if err := a.UpdatePreferences(rctx, targetUserID, prefs); err != nil {
rctx.Logger().Error("Failed to update attach_app_logs preference", mlog.String("user_id", targetUserID), mlog.Err(err))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.update_error.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
logMobileLogsAttachAppLogsAudit(a, rctx, args.UserId, targetUserID, "true")
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.enabled", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
case "off":
prefs := model.Preferences{
{
UserId: targetUserID,
Category: model.PreferenceCategoryAdvancedSettings,
Name: model.PreferenceNameAttachAppLogs,
Value: "false",
},
}
if err := a.UpdatePreferences(rctx, targetUserID, prefs); err != nil {
rctx.Logger().Error("Failed to update attach_app_logs preference", mlog.String("user_id", targetUserID), mlog.Err(err))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.update_error.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
logMobileLogsAttachAppLogsAudit(a, rctx, args.UserId, targetUserID, "false")
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.disabled", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
case "status":
pref, err := a.GetPreferenceByCategoryAndNameForUser(rctx, targetUserID, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
if err != nil {
rctx.Logger().Debug("Could not get attach_app_logs preference, defaulting to off", mlog.String("user_id", targetUserID), mlog.Err(err))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.status_off", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if pref.Value == "true" {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.status_on", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.status_off", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
default:
// Defensive: action is already validated to be "on", "off", or "status" above.
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.usage"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
}