Files
team/server/channels/app/expired_access_token_notify.go
c9e9a2a43e [MM-69693] notify token owners when an expired PAT is deleted (#37349)
* MM-69693: notify token owners when an expired PAT is deleted

Send an at-expiry DM from the cleanup_expired_access_tokens job at the
moment it deletes an expired personal access token. This complements the
pre-expiry warning cascade (MM-69215): for the silent-integration case,
where a fire-and-forget caller swallows the rejection error, the owner is
now told their token stopped working.

The notification fires in the same step as the delete, so no grace period,
tombstone, or new schema is needed and re-notification is inherently
impossible. It is best-effort: sent before the delete (while the
token->owner mapping still exists) and the delete proceeds regardless of
the result. Bot-owned tokens and tokens owned by deactivated users are
skipped, matching the pre-expiry job.

An app-layer notify callback is injected into the worker, mirroring how
expirynotify is wired, so cleanupExpired stays unit-testable with a fake
notifier.

* MM-69693: add app-layer notify tests and isolate notifier panics

Add expired_access_token_notify_test.go covering NotifyExpiredAccessTokensDeleted:
empty list, happy-path DM, bot-owned skip, deactivated-user skip, and
unknown-owner skip with continued processing.

Wrap the notifyExpired call in the cleanup worker with its own recover so a
panic in the best-effort notifier can't abort execute() before DeleteByIds
runs, and add a worker test asserting deletion still happens after a notify
panic.

* MM-69693: drop notifier panic recover from cleanup worker

The job-level HandleJobPanic in execute() is already the panic boundary for
the run, and NotifyExpiredAccessTokensDeleted logs and swallows its own
errors, so the local recover around notifyExpired added no real protection.
Remove it (and its test) along with the now-redundant inline comment; the
notify-before-delete rationale stays documented on cleanupExpired.

* MM-69693: thread request.CTX into NotifyExpiredAccessTokensDeleted

Pass the job's request context from the cleanup worker down through
cleanupExpired into the notify callback instead of building a fresh
EmptyContext inside the app function, so notification logging is correlated
with the job's logger.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-09 11:13:30 +02:00

79 lines
2.8 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"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"
)
// NotifyExpiredAccessTokensDeleted DMs the owner of each expired personal
// access token that the cleanup_expired_access_tokens job is about to delete.
//
// This is the at-expiry safety net that complements the pre-expiry warning
// cascade (pat_expiry_notify): it fires from the cleanup job at the moment a
// token is removed, so an owner whose fire-and-forget integration silently
// swallowed the rejection error still learns their token stopped working.
//
// It is best-effort. It is invoked before the tokens are deleted so the
// token→owner mapping is still available, and every failure is logged and
// swallowed so it never blocks the cleanup. Bot-owned tokens and tokens owned
// by deactivated users are skipped, matching pat_expiry_notify — GetExpiredBefore
// returns those too because the cleanup job must still delete them.
func (a *App) NotifyExpiredAccessTokensDeleted(rctx request.CTX, tokens []*model.UserAccessToken) {
if len(tokens) == 0 {
return
}
systemBot, appErr := a.GetSystemBot(rctx)
if appErr != nil {
rctx.Logger().Error("Failed to get system bot to notify expired personal access token owners", mlog.Err(appErr))
return
}
for _, token := range tokens {
user, appErr := a.GetUser(token.UserId)
if appErr != nil {
rctx.Logger().Warn("Failed to get user for expired personal access token notification",
mlog.String("user_id", token.UserId),
mlog.Err(appErr),
)
continue
}
// Skip bot accounts and deactivated users, matching pat_expiry_notify.
if user.IsBot || user.DeleteAt != 0 {
continue
}
channel, appErr := a.GetOrCreateDirectChannel(rctx, token.UserId, systemBot.UserId)
if appErr != nil {
rctx.Logger().Warn("Failed to get direct channel for expired personal access token notification",
mlog.String("user_id", token.UserId),
mlog.Err(appErr),
)
continue
}
T := i18n.GetUserTranslations(user.Locale)
post := &model.Post{
ChannelId: channel.Id,
Message: T("app.user_access_token.expired_deleted_notification", model.StringInterface{
"Description": token.Description,
}),
Type: model.PostTypeDefault,
UserId: systemBot.UserId,
}
if _, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); appErr != nil {
rctx.Logger().Warn("Failed to send expired personal access token notification",
mlog.String("user_id", token.UserId),
mlog.Err(appErr),
)
}
}
}