mirror of
https://github.com/hanzoai/team.git
synced 2026-11-01 07:55:31 +00:00
* 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]>
151 lines
6.2 KiB
Go
151 lines
6.2 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
)
|
|
|
|
func TestAccessTokenExpiryBucket(t *testing.T) {
|
|
now := int64(1_000_000_000_000)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
expiresAt int64
|
|
expected int
|
|
}{
|
|
{"already expired", now - model.DayInMilliseconds, 0},
|
|
{"expires now", now, 0},
|
|
{"12 hours left -> 1-day bucket", now + 12*60*60*1000, 1},
|
|
{"exactly 1 day left -> 1-day bucket", now + 1*model.DayInMilliseconds, 1},
|
|
{"2 days left -> 3-day bucket", now + 2*model.DayInMilliseconds, 3},
|
|
{"exactly 3 days left -> 3-day bucket", now + 3*model.DayInMilliseconds, 3},
|
|
{"5 days left -> 7-day bucket", now + 5*model.DayInMilliseconds, 7},
|
|
{"exactly 7 days left -> 7-day bucket", now + 7*model.DayInMilliseconds, 7},
|
|
{"8 days left -> outside cascade", now + 8*model.DayInMilliseconds, 0},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
require.Equal(t, tc.expected, accessTokenExpiryBucket(tc.expiresAt, now))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNotifyExpiringAccessTokens(t *testing.T) {
|
|
setup := func(t *testing.T) *TestHelper {
|
|
th := Setup(t).InitBasic(t)
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = true })
|
|
return th
|
|
}
|
|
|
|
systemBotDMMessages := func(t *testing.T, th *TestHelper, userID string) []string {
|
|
systemBot, appErr := th.App.GetSystemBot(th.Context)
|
|
require.Nil(t, appErr)
|
|
channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, userID, systemBot.UserId)
|
|
require.Nil(t, appErr)
|
|
posts, appErr := th.App.GetPosts(th.Context, channel.Id, 0, 50)
|
|
require.Nil(t, appErr)
|
|
var messages []string
|
|
for _, p := range posts.Posts {
|
|
if p.UserId == systemBot.UserId {
|
|
messages = append(messages, p.Message)
|
|
}
|
|
}
|
|
return messages
|
|
}
|
|
|
|
t.Run("disabled when EnableUserAccessTokens is off", func(t *testing.T) {
|
|
th := setup(t)
|
|
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = false })
|
|
|
|
token := &model.UserAccessToken{Token: model.NewId(), UserId: th.BasicUser.Id, Description: "t", ExpiresAt: model.GetMillis() + 5*model.DayInMilliseconds}
|
|
_, err := th.App.Srv().Store().UserAccessToken().Save(token)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, th.App.NotifyExpiringAccessTokens())
|
|
require.Empty(t, systemBotDMMessages(t, th, th.BasicUser.Id))
|
|
})
|
|
|
|
t.Run("warns once and dedups on re-run", func(t *testing.T) {
|
|
th := setup(t)
|
|
|
|
token := &model.UserAccessToken{Token: model.NewId(), UserId: th.BasicUser.Id, Description: "deploy bot", ExpiresAt: model.GetMillis() + 5*model.DayInMilliseconds}
|
|
_, err := th.App.Srv().Store().UserAccessToken().Save(token)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, th.App.NotifyExpiringAccessTokens())
|
|
messages := systemBotDMMessages(t, th, th.BasicUser.Id)
|
|
require.Len(t, messages, 1, "owner should get exactly one warning")
|
|
require.Contains(t, messages[0], "deploy bot", "warning should name the token")
|
|
require.Contains(t, messages[0], "7 days or less", "5-day token is in the 7-day bucket")
|
|
require.Contains(t, messages[0], "regenerate", "warning should tell the owner to regenerate the token")
|
|
require.NotContains(t, messages[0], "day(s)", "message must not use the awkward day(s) plural")
|
|
|
|
stored, err := th.App.Srv().Store().UserAccessToken().Get(token.Id)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored.LastNotifiedAt, "last-notified time should be recorded")
|
|
|
|
// A second run in the same bucket must not send another DM.
|
|
require.NoError(t, th.App.NotifyExpiringAccessTokens())
|
|
require.Len(t, systemBotDMMessages(t, th, th.BasicUser.Id), 1, "re-run in the same bucket must not duplicate")
|
|
})
|
|
|
|
t.Run("token already inside window sends only the current bucket, not a catch-up burst", func(t *testing.T) {
|
|
th := setup(t)
|
|
|
|
// Token surfaces with only 2 days left (e.g. created short-lived, or owner
|
|
// just reactivated). It must get a single warning at the 3-day bucket, never
|
|
// the already-passed 7-day one.
|
|
token := &model.UserAccessToken{Token: model.NewId(), UserId: th.BasicUser.Id, Description: "short lived", ExpiresAt: model.GetMillis() + 2*model.DayInMilliseconds}
|
|
_, err := th.App.Srv().Store().UserAccessToken().Save(token)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, th.App.NotifyExpiringAccessTokens())
|
|
messages := systemBotDMMessages(t, th, th.BasicUser.Id)
|
|
require.Len(t, messages, 1, "exactly one warning, no 7+3 burst")
|
|
require.Contains(t, messages[0], "3 days or less", "2-day token is in the 3-day bucket")
|
|
|
|
stored, err := th.App.Srv().Store().UserAccessToken().Get(token.Id)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored.LastNotifiedAt, "last-notified time should be recorded")
|
|
})
|
|
|
|
t.Run("sub-day token uses the 24-hour wording", func(t *testing.T) {
|
|
th := setup(t)
|
|
|
|
token := &model.UserAccessToken{Token: model.NewId(), UserId: th.BasicUser.Id, Description: "almost gone", ExpiresAt: model.GetMillis() + 12*60*60*1000}
|
|
_, err := th.App.Srv().Store().UserAccessToken().Save(token)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, th.App.NotifyExpiringAccessTokens())
|
|
messages := systemBotDMMessages(t, th, th.BasicUser.Id)
|
|
require.Len(t, messages, 1)
|
|
require.Contains(t, messages[0], "within 24 hours", "sub-day token uses the dedicated 24-hour message")
|
|
require.NotContains(t, messages[0], "day", "the 24-hour message must not mention days")
|
|
})
|
|
|
|
t.Run("bot-owned token is not notified", func(t *testing.T) {
|
|
th := setup(t)
|
|
|
|
bot, appErr := th.App.CreateBot(th.Context, &model.Bot{Username: "expiring_bot", OwnerId: th.BasicUser.Id})
|
|
require.Nil(t, appErr)
|
|
|
|
token := &model.UserAccessToken{Token: model.NewId(), UserId: bot.UserId, Description: "bot token", ExpiresAt: model.GetMillis() + 5*model.DayInMilliseconds}
|
|
_, err := th.App.Srv().Store().UserAccessToken().Save(token)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, th.App.NotifyExpiringAccessTokens())
|
|
require.Empty(t, systemBotDMMessages(t, th, bot.UserId), "bot accounts must not be warned")
|
|
|
|
stored, err := th.App.Srv().Store().UserAccessToken().Get(token.Id)
|
|
require.NoError(t, err)
|
|
require.Nil(t, stored.LastNotifiedAt, "bot token marker must stay unset")
|
|
})
|
|
}
|