Files
team/server/channels/store/storetest/mocks/UserAccessTokenStore.go
ae56750319 [MM-69561] Add ability to rotate (regenerate) Personal Access Tokens (#37295)
* MM-69561: Add ability to rotate (regenerate) Personal Access Tokens

- Add UpdateTokenRotate to UserAccessTokenStore interface and implement
  in sqlstore: deletes sessions on the old secret, then updates the
  token row with the new secret and expiry in one transaction
- Regenerate store retrylayer, timerlayer, and mocks
- Add RotateUserAccessToken app method: validates expiry (bot-exempt),
  captures old session for cache eviction, generates new secret, and
  sends a notification email
- Register POST /api/v4/users/tokens/rotate handler with full permission
  checks (create_user_access_token + edit_other_users + manage_system
  for sysadmin targets); rejects OAuth sessions and disabled tokens
- Add RotateUserAccessToken to the Go client (client4.go)
- Add storetest covering secret rotation and old-session cleanup
- Add API4 tests: happy path, permission denials, OAuth rejection, and
  max-lifetime enforcement

* MM-69561: Add dedicated rotate email and i18n strings

- Add SendUserAccessTokenRotatedEmail (subject/body distinct from the
  'added' email so users aren't confused by a rotation event)
- Add SendUserAccessTokenRotatedEmail to ServiceInterface + mock
- Add en.json strings for the rotate email and the two new error ids
  (rotate.app_error, disabled_token.app_error)
- Switch RotateUserAccessToken to call SendUserAccessTokenRotatedEmail

* MM-69561: Add mmctl token rotate subcommand

- Add RotateUserAccessToken to the mmctl Client interface and mock
- Add 'mmctl token rotate <token-id> [--expires-in <duration>]' command
  reusing the existing resolveTokenExpiry/parseExpiresIn helpers from
  'generate'; prints the new secret once on success
- Add unit tests: happy path, --expires-in passed through, server error,
  invalid --expires-in

* MM-69561: Regenerate mmctl docs for token rotate

* MM-69561: Add API docs for POST /users/tokens/rotate

* MM-69561: Fix i18n string ordering after extract

* MM-69561: Add missing API4 test cases for token rotate

Cover the three untested access-control branches flagged by the test
analysis bot:
- Rotating a disabled token returns 400
- Non-system-admin rotating a sysadmin's token returns 403
- Rotating a remote user's token returns 403

* MM-69561: Address review comments

- Fix handler authorization order: check SessionHasPermissionToUserOrBot
  and manage_system before IsRemote/IsActive to avoid leaking token state
  to unauthorized callers; matches revokeUserAccessToken/disableUserAccessToken
- Fix API docs minimum server version: 10.8 -> 10.10

* MM-69561: Restore i18n strings accidentally deleted by extract

The earlier i18n-extract run stripped ~164 unrelated translation keys
(mostly enterprise-only strings like app.pap.* and api.ldap.*) because
the enterprise codebase isn't present in this checkout, so the
extractor treated them as unused. Restore them while keeping the 5
new keys added for token rotation.

* comment

* [MM-69561] Add webapp Regenerate option for Personal Access Tokens

Adds a "Regenerate" link to Account Settings > Security > Personal
Access Tokens that calls the POST /users/tokens/rotate endpoint added
in the server-side rotate PAT work. Regenerating shows a confirmation
modal naming the token, then reveals the new secret via the existing
one-time-copy flow used for token creation.

- webapp Client4.rotateUserAccessToken
- mattermost-redux rotateUserAccessToken action
- Regenerate link/confirm modal/reveal flow in user_access_token_section
- i18n strings
- Playwright e2e coverage

* Fix regenerate PAT e2e test: confirm modal is not nested in the Profile dialog

ConfirmModal renders via react-bootstrap's Modal, which portals to
document.body as a sibling of the Profile dialog rather than a
descendant, so it must be located via #confirmModal on the page
instead of scoped to the Profile dialog locator.

* Let users pick a new expiry when regenerating a Personal Access Token

Previously, regenerating a token always called rotateUserAccessToken
with no expiresAt, so the rotated secret never expired even if the
original token did. The Regenerate confirmation modal now includes the
same expiry picker used by token creation (extracted into a shared
renderExpiryPicker helper), enforces MaximumPersonalAccessTokenLifetimeDays
the same way, and disables the confirm button until a valid expiry is
selected.

* Scope the red background in the Regenerate modal to the warning text only

The confirmation question, expiry picker, and its hints were sitting
inside the same alert-danger box as the warning, making the whole
modal read as an error. Only the warning paragraph keeps the red
background now.

* Move the regenerate confirmation question below the expiry picker

* Align rotate-token wording with UI: use 'regenerate/regenerated'

* [MM-69561] rename pat_expiry_notify job to notify_expiring_access_tokens

Unifies naming with the sibling cleanup_expired_access_tokens job and
fixes the "expiry" vs "expiring" ambiguity: this job warns about tokens
approaching expiry, not ones that have already expired. Safe to rename
outright since the job hasn't shipped yet.

* [MM-69561] remove dead session lookup from EnableUserAccessToken

The GetSessionContext call and its result were never used: both branches
returned nil regardless. Leftover from mirroring DisableUserAccessToken's
shape, which does use its session (to revoke it) unlike Enable.

* [MM-69561] rename remaining PAT identifiers to match AccessToken convention

The job-level rename (pat_expiry_notify -> notify_expiring_access_tokens)
left the app-layer function and its helpers using the old PAT/
PersonalAccessToken naming. Rename them to match:

- NotifyPersonalAccessTokensExpiring -> NotifyExpiringAccessTokens
- patExpiryBucket -> accessTokenExpiryBucket
- sendPATExpiryNotification -> sendAccessTokenExpiryNotification
- patExpiryNotifyBatchLimit -> expiringAccessTokenBatchLimit
- patExpiryThresholds -> expiringAccessTokenThresholds
- maxPersonalAccessTokenExpiry -> maxUserAccessTokenExpiry

Also reword the package doc on notify_expiring_access_tokens, which no
longer needs to explain a PAT/UserAccessToken naming split now that the
app-layer method matches the job name.

Pure rename, no behavior change.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 23:08:19 +02:00

464 lines
11 KiB
Go

// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make store-mocks`.
package mocks
import (
model "github.com/mattermost/mattermost/server/public/model"
mock "github.com/stretchr/testify/mock"
)
// UserAccessTokenStore is an autogenerated mock type for the UserAccessTokenStore type
type UserAccessTokenStore struct {
mock.Mock
}
// CountNonCompliantExpiry provides a mock function with given fields: maxExpiresAt
func (_m *UserAccessTokenStore) CountNonCompliantExpiry(maxExpiresAt int64) (int64, error) {
ret := _m.Called(maxExpiresAt)
if len(ret) == 0 {
panic("no return value specified for CountNonCompliantExpiry")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func(int64) (int64, error)); ok {
return rf(maxExpiresAt)
}
if rf, ok := ret.Get(0).(func(int64) int64); ok {
r0 = rf(maxExpiresAt)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(int64) error); ok {
r1 = rf(maxExpiresAt)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Delete provides a mock function with given fields: tokenID
func (_m *UserAccessTokenStore) Delete(tokenID string) error {
ret := _m.Called(tokenID)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(tokenID)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteAllForUser provides a mock function with given fields: userID
func (_m *UserAccessTokenStore) DeleteAllForUser(userID string) error {
ret := _m.Called(userID)
if len(ret) == 0 {
panic("no return value specified for DeleteAllForUser")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(userID)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteByIds provides a mock function with given fields: tokenIDs
func (_m *UserAccessTokenStore) DeleteByIds(tokenIDs []string) (int64, error) {
ret := _m.Called(tokenIDs)
if len(ret) == 0 {
panic("no return value specified for DeleteByIds")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func([]string) (int64, error)); ok {
return rf(tokenIDs)
}
if rf, ok := ret.Get(0).(func([]string) int64); ok {
r0 = rf(tokenIDs)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func([]string) error); ok {
r1 = rf(tokenIDs)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DeleteNonCompliantExpiry provides a mock function with given fields: maxExpiresAt, limit
func (_m *UserAccessTokenStore) DeleteNonCompliantExpiry(maxExpiresAt int64, limit int) ([]string, error) {
ret := _m.Called(maxExpiresAt, limit)
if len(ret) == 0 {
panic("no return value specified for DeleteNonCompliantExpiry")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func(int64, int) ([]string, error)); ok {
return rf(maxExpiresAt, limit)
}
if rf, ok := ret.Get(0).(func(int64, int) []string); ok {
r0 = rf(maxExpiresAt, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(int64, int) error); ok {
r1 = rf(maxExpiresAt, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Get provides a mock function with given fields: tokenID
func (_m *UserAccessTokenStore) Get(tokenID string) (*model.UserAccessToken, error) {
ret := _m.Called(tokenID)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 *model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(string) (*model.UserAccessToken, error)); ok {
return rf(tokenID)
}
if rf, ok := ret.Get(0).(func(string) *model.UserAccessToken); ok {
r0 = rf(tokenID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(tokenID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAll provides a mock function with given fields: offset, limit
func (_m *UserAccessTokenStore) GetAll(offset int, limit int) ([]*model.UserAccessToken, error) {
ret := _m.Called(offset, limit)
if len(ret) == 0 {
panic("no return value specified for GetAll")
}
var r0 []*model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(int, int) ([]*model.UserAccessToken, error)); ok {
return rf(offset, limit)
}
if rf, ok := ret.Get(0).(func(int, int) []*model.UserAccessToken); ok {
r0 = rf(offset, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(int, int) error); ok {
r1 = rf(offset, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetByToken provides a mock function with given fields: tokenString
func (_m *UserAccessTokenStore) GetByToken(tokenString string) (*model.UserAccessToken, error) {
ret := _m.Called(tokenString)
if len(ret) == 0 {
panic("no return value specified for GetByToken")
}
var r0 *model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(string) (*model.UserAccessToken, error)); ok {
return rf(tokenString)
}
if rf, ok := ret.Get(0).(func(string) *model.UserAccessToken); ok {
r0 = rf(tokenString)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(tokenString)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetByUser provides a mock function with given fields: userID, page, perPage
func (_m *UserAccessTokenStore) GetByUser(userID string, page int, perPage int) ([]*model.UserAccessToken, error) {
ret := _m.Called(userID, page, perPage)
if len(ret) == 0 {
panic("no return value specified for GetByUser")
}
var r0 []*model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(string, int, int) ([]*model.UserAccessToken, error)); ok {
return rf(userID, page, perPage)
}
if rf, ok := ret.Get(0).(func(string, int, int) []*model.UserAccessToken); ok {
r0 = rf(userID, page, perPage)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(string, int, int) error); ok {
r1 = rf(userID, page, perPage)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExpiredBefore provides a mock function with given fields: cutoff, limit
func (_m *UserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) {
ret := _m.Called(cutoff, limit)
if len(ret) == 0 {
panic("no return value specified for GetExpiredBefore")
}
var r0 []*model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(int64, int) ([]*model.UserAccessToken, error)); ok {
return rf(cutoff, limit)
}
if rf, ok := ret.Get(0).(func(int64, int) []*model.UserAccessToken); ok {
r0 = rf(cutoff, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(int64, int) error); ok {
r1 = rf(cutoff, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExpiringTokens provides a mock function with given fields: now, thresholds, limit
func (_m *UserAccessTokenStore) GetExpiringTokens(now int64, thresholds []int, limit int) ([]*model.UserAccessToken, error) {
ret := _m.Called(now, thresholds, limit)
if len(ret) == 0 {
panic("no return value specified for GetExpiringTokens")
}
var r0 []*model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(int64, []int, int) ([]*model.UserAccessToken, error)); ok {
return rf(now, thresholds, limit)
}
if rf, ok := ret.Get(0).(func(int64, []int, int) []*model.UserAccessToken); ok {
r0 = rf(now, thresholds, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(int64, []int, int) error); ok {
r1 = rf(now, thresholds, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Save provides a mock function with given fields: token
func (_m *UserAccessTokenStore) Save(token *model.UserAccessToken) (*model.UserAccessToken, error) {
ret := _m.Called(token)
if len(ret) == 0 {
panic("no return value specified for Save")
}
var r0 *model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(*model.UserAccessToken) (*model.UserAccessToken, error)); ok {
return rf(token)
}
if rf, ok := ret.Get(0).(func(*model.UserAccessToken) *model.UserAccessToken); ok {
r0 = rf(token)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(*model.UserAccessToken) error); ok {
r1 = rf(token)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Search provides a mock function with given fields: term
func (_m *UserAccessTokenStore) Search(term string) ([]*model.UserAccessToken, error) {
ret := _m.Called(term)
if len(ret) == 0 {
panic("no return value specified for Search")
}
var r0 []*model.UserAccessToken
var r1 error
if rf, ok := ret.Get(0).(func(string) ([]*model.UserAccessToken, error)); ok {
return rf(term)
}
if rf, ok := ret.Get(0).(func(string) []*model.UserAccessToken); ok {
r0 = rf(term)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.UserAccessToken)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(term)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateLastNotifiedAt provides a mock function with given fields: tokenID, notifiedAt
func (_m *UserAccessTokenStore) UpdateLastNotifiedAt(tokenID string, notifiedAt int64) error {
ret := _m.Called(tokenID, notifiedAt)
if len(ret) == 0 {
panic("no return value specified for UpdateLastNotifiedAt")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, int64) error); ok {
r0 = rf(tokenID, notifiedAt)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateTokenDisable provides a mock function with given fields: tokenID
func (_m *UserAccessTokenStore) UpdateTokenDisable(tokenID string) error {
ret := _m.Called(tokenID)
if len(ret) == 0 {
panic("no return value specified for UpdateTokenDisable")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(tokenID)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateTokenEnable provides a mock function with given fields: tokenID
func (_m *UserAccessTokenStore) UpdateTokenEnable(tokenID string) error {
ret := _m.Called(tokenID)
if len(ret) == 0 {
panic("no return value specified for UpdateTokenEnable")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(tokenID)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateTokenRotate provides a mock function with given fields: tokenID, newToken, expiresAt
func (_m *UserAccessTokenStore) UpdateTokenRotate(tokenID string, newToken string, expiresAt int64) error {
ret := _m.Called(tokenID, newToken, expiresAt)
if len(ret) == 0 {
panic("no return value specified for UpdateTokenRotate")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, int64) error); ok {
r0 = rf(tokenID, newToken, expiresAt)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewUserAccessTokenStore creates a new instance of UserAccessTokenStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewUserAccessTokenStore(t interface {
mock.TestingT
Cleanup(func())
}) *UserAccessTokenStore {
mock := &UserAccessTokenStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}