diff --git a/api/v4/source/users.yaml b/api/v4/source/users.yaml index 8e04c19892e..5ba9fec6d26 100644 --- a/api/v4/source/users.yaml +++ b/api/v4/source/users.yaml @@ -2611,6 +2611,82 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/users/tokens/non_compliant/count: + get: + tags: + - users + summary: Count non-compliant personal access tokens + description: > + Count the active personal access tokens that violate the configured + `ServiceSettings.MaximumPersonalAccessTokenLifetimeDays` policy (tokens + that never expire or expire beyond the cap). Bot account tokens are + exempt and never counted. Returns 0 when no maximum lifetime is + configured. + + + __Minimum server version__: 11.1 + + + ##### Permissions + + Must have `manage_system` permission. + operationId: GetNonCompliantUserAccessTokenCount + responses: + "200": + description: Count retrieved successfully + content: + application/json: + schema: + type: object + properties: + count: + description: The number of non-compliant personal access tokens + type: integer + format: int64 + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v4/users/tokens/non_compliant/revoke: + post: + tags: + - users + summary: Revoke non-compliant personal access tokens + description: > + Revoke (hard-delete) every active personal access token that violates + the configured `ServiceSettings.MaximumPersonalAccessTokenLifetimeDays` + policy, along with any sessions created from them, and return the number + of tokens revoked. Bot account tokens are exempt. The request is + rejected with 400 when no maximum lifetime is configured, since there is + nothing to revoke. This is irreversible; use + `/users/tokens/non_compliant/count` first to preview the blast radius. + + + __Minimum server version__: 11.1 + + + ##### Permissions + + Must have `manage_system` permission. + operationId: RevokeNonCompliantUserAccessTokens + responses: + "200": + description: Non-compliant tokens revoked successfully + content: + application/json: + schema: + type: object + properties: + count: + description: The number of personal access tokens revoked + type: integer + format: int64 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "/api/v4/users/tokens/{token_id}": get: tags: diff --git a/e2e-tests/playwright/specs/functional/system_console/integration_management/revoke_non_compliant_tokens.spec.ts b/e2e-tests/playwright/specs/functional/system_console/integration_management/revoke_non_compliant_tokens.spec.ts new file mode 100644 index 00000000000..0da88508ebc --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/integration_management/revoke_non_compliant_tokens.spec.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client4} from '@mattermost/client'; + +import {expect, test, testConfig} from '@mattermost/playwright-lib'; + +/** + * E2E coverage for the "Revoke non-compliant tokens" admin console control added in + * MM-69075 (System Console > Integrations > Integration Management). + * + * A token is non-compliant once ServiceSettings.MaximumPersonalAccessTokenLifetimeDays > 0 + * and the token never expires, or expires beyond that cap. The policy only applies at + * creation time, so every seeded token below is created before the cap is patched in. + * Bot account tokens are exempt regardless of the policy. + * + * The non-compliant count and revoke operation are global (every user's tokens, not just + * the test's own), and this server is shared across concurrently running tests/workers. + * So assertions here check per-token outcomes (does this specific token still authenticate) + * and UI state transitions (enabled/disabled, which banner mode) rather than exact global + * counts, which would be flaky. See commit 56716c3616 for the same lesson learned in the + * server-side store tests. + */ + +const DAY_MS = 24 * 60 * 60 * 1000; +const TOKEN_ROLES = 'system_user system_user_access_token'; + +// Returns whether the given personal access token can still authenticate. The token +// secret is only present on the UserAccessToken returned at creation time. +async function tokenIsUsable(token: string | undefined): Promise { + if (!token) { + throw new Error('Expected a token secret, but none was returned by createUserAccessToken'); + } + + const client = new Client4(); + client.setUrl(testConfig.baseURL); + client.setToken(token); + try { + await client.getMe(); + return true; + } catch { + return false; + } +} + +test.describe('System Console > Integrations > Revoke non-compliant tokens @system_console', () => { + test('disables the button and shows a compliant banner when no policy is configured', async ({pw}) => { + const {adminUser, adminClient, user} = await pw.initSetup(); + await adminClient.patchConfig({ + ServiceSettings: {EnableUserAccessTokens: true, MaximumPersonalAccessTokenLifetimeDays: 0}, + }); + await adminClient.updateUserRoles(user.id, TOKEN_ROLES); + await pw.waitUntil(async () => { + const cfg = await adminClient.getConfig(); + return cfg.ServiceSettings?.MaximumPersonalAccessTokenLifetimeDays === 0; + }); + + // # A never-expiring token is fine while there is no cap + const token = await adminClient.createUserAccessToken(user.id, 'never expires token'); + + const {systemConsolePage, page} = await pw.testBrowser.login(adminUser); + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + await systemConsolePage.sidebar.integrations.integrationManagement.click(); + await page.waitForURL(/\/admin_console\/integrations\/integration_management/); + + const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings'); + await expect(section).toBeVisible(); + + // * With no policy configured the server-side count is always 0 (nothing is + // "non-compliant" without a cap to violate), so the button is deterministically disabled + await expect(section.getByText('No personal access tokens currently need to be revoked.')).toBeVisible(); + await expect(section.getByRole('button', {name: 'Revoke non-compliant tokens'})).toBeDisabled(); + expect(await tokenIsUsable(token.token)).toBe(true); + }); + + test('shows a violation and reveals a confirmation modal that can be dismissed without revoking', async ({pw}) => { + const {adminUser, adminClient, user} = await pw.initSetup(); + await adminClient.patchConfig({ServiceSettings: {EnableUserAccessTokens: true}}); + await adminClient.updateUserRoles(user.id, TOKEN_ROLES); + + // # Seed a never-expiring token while there is no cap, then enable the cap so it becomes non-compliant + const token = await adminClient.createUserAccessToken(user.id, 'never expires token'); + await adminClient.patchConfig({ServiceSettings: {MaximumPersonalAccessTokenLifetimeDays: 30}}); + await pw.waitUntil(async () => { + const cfg = await adminClient.getConfig(); + return cfg.ServiceSettings?.MaximumPersonalAccessTokenLifetimeDays === 30; + }); + + const {systemConsolePage, page} = await pw.testBrowser.login(adminUser); + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + await systemConsolePage.sidebar.integrations.integrationManagement.click(); + await page.waitForURL(/\/admin_console\/integrations\/integration_management/); + + const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings'); + await expect(section).toBeVisible(); + + // * At least one violation is now shown (ours), and the button is enabled + await expect( + section.getByText(/\d+ personal access tokens? currently violates? the maximum lifetime policy\./), + ).toBeVisible(); + const revokeButton = section.getByRole('button', {name: 'Revoke non-compliant tokens'}); + await expect(revokeButton).toBeEnabled(); + + // # Open the confirmation and cancel it + await revokeButton.click(); + const confirmModal = page.locator('#confirmModal'); + await expect(confirmModal.getByText('Revoke non-compliant personal access tokens?')).toBeVisible(); + await expect(confirmModal.getByText(/This will permanently revoke \d+ personal access tokens?/)).toBeVisible(); + await confirmModal.getByRole('button', {name: 'Cancel'}).click(); + await expect(confirmModal).toBeHidden(); + + // * Cancelling did not revoke our token + expect(await tokenIsUsable(token.token)).toBe(true); + }); + + test('revokes non-compliant tokens on confirm, invalidating them while compliant and bot tokens survive', async ({ + pw, + }) => { + const {adminUser, adminClient, user} = await pw.initSetup(); + await adminClient.patchConfig({ + ServiceSettings: {EnableUserAccessTokens: true, EnableBotAccountCreation: true}, + }); + await adminClient.updateUserRoles(user.id, TOKEN_ROLES); + + // # Seed one non-compliant token, one compliant token, and one exempt bot token, all + // # before the cap is enabled so the server allows their creation. + const nonCompliantToken = await adminClient.createUserAccessToken(user.id, 'never expires token'); + const compliantToken = await adminClient.createUserAccessToken( + user.id, + 'compliant token', + Date.now() + 10 * DAY_MS, + ); + const bot = await adminClient.createBot({ + username: `revoke-bot-${user.id.slice(0, 8)}`, + display_name: 'Revoke test bot', + }); + const botToken = await adminClient.createUserAccessToken(bot.user_id, 'bot token'); + + await adminClient.patchConfig({ServiceSettings: {MaximumPersonalAccessTokenLifetimeDays: 30}}); + await pw.waitUntil(async () => { + const cfg = await adminClient.getConfig(); + return cfg.ServiceSettings?.MaximumPersonalAccessTokenLifetimeDays === 30; + }); + + const {systemConsolePage, page} = await pw.testBrowser.login(adminUser); + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + await systemConsolePage.sidebar.integrations.integrationManagement.click(); + await page.waitForURL(/\/admin_console\/integrations\/integration_management/); + + const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings'); + await expect(section).toBeVisible(); + + // # Confirm the revoke + await section.getByRole('button', {name: 'Revoke non-compliant tokens'}).click(); + const confirmModal = page.locator('#confirmModal'); + await confirmModal.getByRole('button', {name: 'Revoke tokens'}).click(); + + // * The success banner reports how many were revoked, and the button disables again + // (nothing left to revoke, since a revoke sweeps every non-compliant token server-wide) + await expect(section.getByText(/Revoked \d+ non-compliant personal access tokens?\./)).toBeVisible(); + await expect(section.getByRole('button', {name: 'Revoke non-compliant tokens'})).toBeDisabled(); + + // * The non-compliant token can no longer authenticate + await expect(async () => { + expect(await tokenIsUsable(nonCompliantToken.token)).toBe(false); + }).toPass(); + + // * The compliant token and the exempt bot token still authenticate + expect(await tokenIsUsable(compliantToken.token)).toBe(true); + expect(await tokenIsUsable(botToken.token)).toBe(true); + }); + + test('refreshes the violation banner after saving a new maximum lifetime policy from the same page', async ({ + pw, + }) => { + const {adminUser, adminClient, user} = await pw.initSetup(); + await adminClient.patchConfig({ + ServiceSettings: {EnableUserAccessTokens: true, MaximumPersonalAccessTokenLifetimeDays: 0}, + }); + await adminClient.updateUserRoles(user.id, TOKEN_ROLES); + + // # Seed a never-expiring token while there is no cap + const token = await adminClient.createUserAccessToken(user.id, 'never expires token'); + + const {systemConsolePage, page} = await pw.testBrowser.login(adminUser); + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + await systemConsolePage.sidebar.integrations.integrationManagement.click(); + await page.waitForURL(/\/admin_console\/integrations\/integration_management/); + + const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings'); + await expect(section).toBeVisible(); + + // # Set a maximum lifetime and save, without leaving the page + const maxLifetimeInput = section.getByTestId('ServiceSettings.MaximumPersonalAccessTokenLifetimeDaysnumber'); + await maxLifetimeInput.fill('30'); + await section.getByRole('button', {name: 'Save'}).click(); + + // * The banner refreshes to flag the newly non-compliant token, without a page reload + await expect( + section.getByText(/\d+ personal access tokens? currently violates? the maximum lifetime policy\./), + ).toBeVisible(); + await expect(section.getByRole('button', {name: 'Revoke non-compliant tokens'})).toBeEnabled(); + expect(await tokenIsUsable(token.token)).toBe(true); + }); +}); diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index 17ee820db0f..e6d273a6514 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -90,6 +90,8 @@ func (api *API) InitUser() { api.BaseRoutes.User.Handle("/tokens", api.APISessionRequired(getUserAccessTokensForUser)).Methods(http.MethodGet) api.BaseRoutes.Users.Handle("/tokens", api.APISessionRequired(getUserAccessTokens)).Methods(http.MethodGet) api.BaseRoutes.Users.Handle("/tokens/search", api.APISessionRequired(searchUserAccessTokens)).Methods(http.MethodPost) + api.BaseRoutes.Users.Handle("/tokens/non_compliant/count", api.APISessionRequired(countNonCompliantUserAccessTokens)).Methods(http.MethodGet) + api.BaseRoutes.Users.Handle("/tokens/non_compliant/revoke", api.APISessionRequired(revokeNonCompliantUserAccessTokens)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/tokens/{token_id:[A-Za-z0-9]+}", api.APISessionRequired(getUserAccessToken)).Methods(http.MethodGet) api.BaseRoutes.Users.Handle("/tokens/revoke", api.APISessionRequired(revokeUserAccessToken)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/tokens/disable", api.APISessionRequired(disableUserAccessToken)).Methods(http.MethodPost) @@ -3062,6 +3064,58 @@ func getUserAccessTokens(c *Context, w http.ResponseWriter, r *http.Request) { } } +func countNonCompliantUserAccessTokens(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + count, appErr := c.App.CountNonCompliantUserAccessTokens() + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(model.NonCompliantUserAccessTokenResult{Count: count}) + if err != nil { + c.Err = model.NewAppError("countNonCompliantUserAccessTokens", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func revokeNonCompliantUserAccessTokens(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord(model.AuditEventRevokeNonCompliantUserAccessTokens, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + count, appErr := c.App.RevokeNonCompliantUserAccessTokens(c.AppContext) + if appErr != nil { + c.Err = appErr + return + } + + model.AddEventParameterToAuditRec(auditRec, "revoked_count", count) + auditRec.Success() + + js, err := json.Marshal(model.NonCompliantUserAccessTokenResult{Count: count}) + if err != nil { + c.Err = model.NewAppError("revokeNonCompliantUserAccessTokens", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func getUserAccessTokensForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireUserId() if c.Err != nil { diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index 46d9a6ab6f4..b1bec4fa024 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -6537,6 +6537,131 @@ func TestGetUserAccessTokens(t *testing.T) { }) } +// seedNonCompliantTokens creates a mix of tokens for a user while no lifetime +// policy is in effect (so never-expiring and far-future tokens can be saved), +// plus a bot token, and returns the IDs of the tokens that should be considered +// non-compliant once a 30-day policy is enabled. +func seedNonCompliantTokens(t *testing.T, th *TestHelper) (nonCompliantIDs []string, compliantID string, botTokenID string) { + t.Helper() + + day := int64(24 * 60 * 60 * 1000) + + // Never-expiring token — non-compliant once a policy requires expiry. + noExpiry, appErr := th.App.CreateUserAccessToken(th.Context, &model.UserAccessToken{UserId: th.BasicUser.Id, Description: "no expiry"}) + require.Nil(t, appErr) + + // Far-future token beyond the 30-day cap — non-compliant. + farFuture, appErr := th.App.CreateUserAccessToken(th.Context, &model.UserAccessToken{UserId: th.BasicUser.Id, Description: "far future", ExpiresAt: model.GetMillis() + 60*day}) + require.Nil(t, appErr) + + // Token expiring within the cap — compliant, must survive. + compliant, appErr := th.App.CreateUserAccessToken(th.Context, &model.UserAccessToken{UserId: th.BasicUser.Id, Description: "compliant", ExpiresAt: model.GetMillis() + 10*day}) + require.Nil(t, appErr) + + // Bot token with no expiry — bots are exempt from the policy, must survive. + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableBotAccountCreation = true }) + bot := th.CreateBotWithSystemAdminClient(t) + botToken, appErr := th.App.CreateUserAccessToken(th.Context, &model.UserAccessToken{UserId: bot.UserId, Description: "bot token"}) + require.Nil(t, appErr) + + return []string{noExpiry.Id, farFuture.Id}, compliant.Id, botToken.Id +} + +func TestGetNonCompliantUserAccessTokenCount(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("forbidden for non-admin", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + _, resp, err := th.Client.GetNonCompliantUserAccessTokenCount(context.Background()) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("returns zero when no policy is configured", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = true }) + seedNonCompliantTokens(t, th) + + result, _, err := th.SystemAdminClient.GetNonCompliantUserAccessTokenCount(context.Background()) + require.NoError(t, err) + assert.Equal(t, int64(0), result.Count) + }) + + t.Run("counts only non-compliant non-bot tokens", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = true }) + seedNonCompliantTokens(t, th) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.MaximumPersonalAccessTokenLifetimeDays = 30 }) + + result, _, err := th.SystemAdminClient.GetNonCompliantUserAccessTokenCount(context.Background()) + require.NoError(t, err) + assert.Equal(t, int64(2), result.Count) + }) +} + +func TestRevokeNonCompliantUserAccessTokens(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("forbidden for non-admin", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + _, resp, err := th.Client.RevokeNonCompliantUserAccessTokens(context.Background()) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("refused when no policy is configured", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = true }) + seedNonCompliantTokens(t, th) + + _, resp, err := th.SystemAdminClient.RevokeNonCompliantUserAccessTokens(context.Background()) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("revokes only non-compliant non-bot tokens", func(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableUserAccessTokens = true }) + nonCompliantIDs, compliantID, botTokenID := seedNonCompliantTokens(t, th) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.MaximumPersonalAccessTokenLifetimeDays = 30 }) + + result, _, err := th.SystemAdminClient.RevokeNonCompliantUserAccessTokens(context.Background()) + require.NoError(t, err) + assert.Equal(t, int64(2), result.Count) + + // Non-compliant tokens are gone. + for _, id := range nonCompliantIDs { + _, appErr := th.App.GetUserAccessToken(id, false) + require.NotNil(t, appErr) + } + + // Compliant and bot tokens survive. + _, appErr := th.App.GetUserAccessToken(compliantID, false) + require.Nil(t, appErr) + _, appErr = th.App.GetUserAccessToken(botTokenID, false) + require.Nil(t, appErr) + + // A second run is now a no-op. + result, _, err = th.SystemAdminClient.RevokeNonCompliantUserAccessTokens(context.Background()) + require.NoError(t, err) + assert.Equal(t, int64(0), result.Count) + }) +} + func TestSearchUserAccessToken(t *testing.T) { mainHelper.Parallel(t) diff --git a/server/channels/app/session.go b/server/channels/app/session.go index dfa4d111562..f9b67fbe0ae 100644 --- a/server/channels/app/session.go +++ b/server/channels/app/session.go @@ -699,6 +699,102 @@ func (a *App) RevokeUserAccessToken(rctx request.CTX, token *model.UserAccessTok return a.RevokeSession(rctx, session) } +// revokeNonCompliantBatchLimit bounds both the number of rows fetched by +// GetNonCompliantExpiry and the corresponding DeleteByIds call, keeping the +// transaction footprint bounded even when a large number of tokens are +// non-compliant. revokeNonCompliantMaxBatches caps the iterations of a single +// revoke call so a runaway loop can't develop. +const ( + revokeNonCompliantBatchLimit = 1000 + revokeNonCompliantMaxBatches = 1000 +) + +// maxPersonalAccessTokenExpiry returns the latest ExpiresAt a token may carry to +// comply with the current ServiceSettings.MaximumPersonalAccessTokenLifetimeDays +// policy, along with whether a policy is in effect. When no maximum is +// configured (0), no policy applies and every token is compliant. +func (a *App) maxPersonalAccessTokenExpiry() (maxExpiresAt int64, enabled bool) { + cfg := a.Config().ServiceSettings + + maxDays := int64(0) + if cfg.MaximumPersonalAccessTokenLifetimeDays != nil { + maxDays = int64(*cfg.MaximumPersonalAccessTokenLifetimeDays) + } + + if maxDays <= 0 { + return 0, false + } + + return model.GetMillis() + maxDays*24*60*60*1000, true +} + +// CountNonCompliantUserAccessTokens returns the number of active, non-bot +// personal access tokens that violate the current maximum lifetime policy. It +// lets an admin preview the blast radius before revoking. When no policy is in +// effect it returns 0 — nothing is non-compliant. +func (a *App) CountNonCompliantUserAccessTokens() (int64, *model.AppError) { + maxExpiresAt, enabled := a.maxPersonalAccessTokenExpiry() + if !enabled { + return 0, nil + } + + count, err := a.Srv().Store().UserAccessToken().CountNonCompliantExpiry(maxExpiresAt) + if err != nil { + return 0, model.NewAppError("CountNonCompliantUserAccessTokens", "app.user_access_token.count_non_compliant.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return count, nil +} + +// RevokeNonCompliantUserAccessTokens hard-deletes every active, non-bot personal +// access token that violates the current maximum lifetime policy, along with any +// sessions minted from them. It returns the number of tokens actually deleted. +// Work is done in bounded batches to keep transactions small, and the per-user +// session cache is cleared so stale sessions aren't served from memory. When no +// policy is in effect the call is refused — there is nothing to revoke and a +// caller reaching this path likely has a stale view of the config. Auditing is +// the caller's responsibility, matching RevokeUserAccessToken. +func (a *App) RevokeNonCompliantUserAccessTokens(rctx request.CTX) (int64, *model.AppError) { + maxExpiresAt, enabled := a.maxPersonalAccessTokenExpiry() + if !enabled { + return 0, model.NewAppError("RevokeNonCompliantUserAccessTokens", "app.user_access_token.revoke_non_compliant.no_policy.app_error", nil, "", http.StatusBadRequest) + } + + var totalRevoked int64 + allRevoked := false + for range revokeNonCompliantMaxBatches { + userIDs, err := a.Srv().Store().UserAccessToken().DeleteNonCompliantExpiry(maxExpiresAt, revokeNonCompliantBatchLimit) + if err != nil { + return totalRevoked, model.NewAppError("RevokeNonCompliantUserAccessTokens", "app.user_access_token.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if len(userIDs) == 0 { + allRevoked = true + break + } + + totalRevoked += int64(len(userIDs)) + + seen := make(map[string]struct{}, len(userIDs)) + for _, userID := range userIDs { + if _, ok := seen[userID]; !ok { + seen[userID] = struct{}{} + a.ClearSessionCacheForUser(userID) + } + } + + if len(userIDs) < revokeNonCompliantBatchLimit { + allRevoked = true + break + } + } + + if !allRevoked { + return totalRevoked, model.NewAppError("RevokeNonCompliantUserAccessTokens", "app.user_access_token.revoke_non_compliant.partial.app_error", nil, "", http.StatusInternalServerError) + } + + return totalRevoked, nil +} + func (a *App) DisableUserAccessToken(rctx request.CTX, token *model.UserAccessToken) *model.AppError { var session *model.Session session, _ = a.ch.srv.platform.GetSessionContext(rctx, token.Token) diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 7114c1d2601..f10d4be8059 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -18021,6 +18021,27 @@ func (s *RetryLayerUserStore) VerifyEmail(userID string, email string) (string, } +func (s *RetryLayerUserAccessTokenStore) CountNonCompliantExpiry(maxExpiresAt int64) (int64, error) { + + tries := 0 + for { + result, err := s.UserAccessTokenStore.CountNonCompliantExpiry(maxExpiresAt) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserAccessTokenStore) Delete(tokenID string) error { tries := 0 @@ -18084,6 +18105,27 @@ func (s *RetryLayerUserAccessTokenStore) DeleteByIds(tokenIDs []string) (int64, } +func (s *RetryLayerUserAccessTokenStore) DeleteNonCompliantExpiry(maxExpiresAt int64, limit int) ([]string, error) { + + tries := 0 + for { + result, err := s.UserAccessTokenStore.DeleteNonCompliantExpiry(maxExpiresAt, limit) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserAccessTokenStore) Get(tokenID string) (*model.UserAccessToken, error) { tries := 0 diff --git a/server/channels/store/sqlstore/user_access_token_store.go b/server/channels/store/sqlstore/user_access_token_store.go index ba33898c35c..e35a11ffa63 100644 --- a/server/channels/store/sqlstore/user_access_token_store.go +++ b/server/channels/store/sqlstore/user_access_token_store.go @@ -270,6 +270,65 @@ func (s SqlUserAccessTokenStore) GetExpiredBefore(cutoff int64, limit int) ([]*m return tokens, nil } +// CountNonCompliantExpiry returns the number of active, non-bot tokens that +// violate the maximum lifetime policy implied by maxExpiresAt. It is used to +// preview the blast radius before revoking. +func (s SqlUserAccessTokenStore) CountNonCompliantExpiry(maxExpiresAt int64) (int64, error) { + query := s.getQueryBuilder(). + Select("COUNT(*)"). + From("UserAccessTokens"). + Where(sq.Or{ + sq.Eq{"UserAccessTokens.ExpiresAt": 0}, + sq.Gt{"UserAccessTokens.ExpiresAt": maxExpiresAt}, + }). + Where(sq.Eq{"UserAccessTokens.IsActive": true}). + Where(sq.Expr("UserAccessTokens.UserId NOT IN (SELECT UserId FROM Bots)")) + + var count int64 + if err := s.GetReplica().GetBuilder(&count, query); err != nil { + return 0, errors.Wrap(err, "failed to count non-compliant UserAccessTokens") + } + + return count, nil +} + +// DeleteNonCompliantExpiry hard-deletes up to limit non-compliant tokens and +// their associated sessions in a single transaction, returning one UserId per +// deleted token row so the caller can count deletions and clear per-user +// session caches. A non-positive limit returns nil without hitting the DB. +func (s SqlUserAccessTokenStore) DeleteNonCompliantExpiry(maxExpiresAt int64, limit int) ([]string, error) { + if limit <= 0 { + return nil, nil + } + + sql := ` +WITH to_delete AS ( + SELECT Id, Token, UserId + FROM UserAccessTokens + WHERE (ExpiresAt = 0 OR ExpiresAt > $1) + AND IsActive = true + AND UserId NOT IN (SELECT UserId FROM Bots) + LIMIT $2 +), +deleted_sessions AS ( + DELETE FROM Sessions + WHERE Token IN (SELECT Token FROM to_delete) +), +deleted_tokens AS ( + DELETE FROM UserAccessTokens + WHERE Id IN (SELECT Id FROM to_delete) + RETURNING UserId +) +SELECT UserId FROM deleted_tokens` + + var userIDs []string + if err := s.GetMaster().Select(&userIDs, sql, maxExpiresAt, limit); err != nil { + return nil, errors.Wrap(err, "failed to delete non-compliant UserAccessTokens") + } + + return userIDs, nil +} + // DeleteByIds deletes the tokens identified by tokenIDs along with any sessions // minted from those tokens, all within a single transaction. It returns the // number of UserAccessTokens rows actually deleted. diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 0d0dbdd0c8f..66d490db067 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -852,6 +852,8 @@ type UserAccessTokenStore interface { GetByToken(tokenString string) (*model.UserAccessToken, error) GetByUser(userID string, page, perPage int) ([]*model.UserAccessToken, error) GetExpiredBefore(cutoff int64, limit int) ([]*model.UserAccessToken, error) + CountNonCompliantExpiry(maxExpiresAt int64) (int64, error) + DeleteNonCompliantExpiry(maxExpiresAt int64, limit int) ([]string, error) Search(term string) ([]*model.UserAccessToken, error) UpdateTokenEnable(tokenID string) error UpdateTokenDisable(tokenID string) error diff --git a/server/channels/store/storetest/mocks/UserAccessTokenStore.go b/server/channels/store/storetest/mocks/UserAccessTokenStore.go index 8ac6117e3db..0546df2c54a 100644 --- a/server/channels/store/storetest/mocks/UserAccessTokenStore.go +++ b/server/channels/store/storetest/mocks/UserAccessTokenStore.go @@ -14,6 +14,34 @@ 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) @@ -78,6 +106,36 @@ func (_m *UserAccessTokenStore) DeleteByIds(tokenIDs []string) (int64, error) { 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) diff --git a/server/channels/store/storetest/user_access_token_store.go b/server/channels/store/storetest/user_access_token_store.go index 6f972384483..bea2ab93614 100644 --- a/server/channels/store/storetest/user_access_token_store.go +++ b/server/channels/store/storetest/user_access_token_store.go @@ -19,6 +19,7 @@ func TestUserAccessTokenStore(t *testing.T, rctx request.CTX, ss store.Store) { t.Run("UserAccessTokenSearch", func(t *testing.T) { testUserAccessTokenSearch(t, rctx, ss) }) t.Run("UserAccessTokenPagination", func(t *testing.T) { testUserAccessTokenPagination(t, rctx, ss) }) t.Run("UserAccessTokenExpiry", func(t *testing.T) { testUserAccessTokenExpiry(t, rctx, ss) }) + t.Run("UserAccessTokenNonCompliant", func(t *testing.T) { testUserAccessTokenNonCompliant(t, rctx, ss) }) } func testUserAccessTokenSaveGetDelete(t *testing.T, rctx request.CTX, ss store.Store) { @@ -356,3 +357,152 @@ func testUserAccessTokenExpiry(t *testing.T, rctx request.CTX, ss store.Store) { require.NoError(t, err) require.Equal(t, int64(0), deleted) } + +func testUserAccessTokenNonCompliant(t *testing.T, rctx request.CTX, ss store.Store) { + now := model.GetMillis() + day := int64(24 * 60 * 60 * 1000) + // maxExpiresAt is the latest expiry a 30-day policy permits. + maxExpiresAt := now + 30*day + // farCap is a much larger cap: only never-expiring tokens violate it. + farCap := now + 1000*day + + // The store counts non-compliant tokens DB-wide and other suite fixtures may + // linger, so assert deltas against a baseline rather than absolute totals. + baseline30, err := ss.UserAccessToken().CountNonCompliantExpiry(maxExpiresAt) + require.NoError(t, err) + baselineFar, err := ss.UserAccessToken().CountNonCompliantExpiry(farCap) + require.NoError(t, err) + + // Never-expiring active token — non-compliant. + noExpiry := &model.UserAccessToken{Token: model.NewId(), UserId: model.NewId(), Description: "no expiry"} + _, err = ss.UserAccessToken().Save(noExpiry) + require.NoError(t, err) + + // Active token expiring beyond the cap — non-compliant. + farFuture := &model.UserAccessToken{Token: model.NewId(), UserId: model.NewId(), Description: "far future", ExpiresAt: now + 60*day} + _, err = ss.UserAccessToken().Save(farFuture) + require.NoError(t, err) + + // Active token expiring within the cap — compliant. + compliant := &model.UserAccessToken{Token: model.NewId(), UserId: model.NewId(), Description: "compliant", ExpiresAt: now + 10*day} + _, err = ss.UserAccessToken().Save(compliant) + require.NoError(t, err) + + // Disabled never-expiring token — non-compliant by expiry, but inactive + // tokens cannot authenticate and are excluded. + inactive := &model.UserAccessToken{Token: model.NewId(), UserId: model.NewId(), Description: "inactive"} + _, err = ss.UserAccessToken().Save(inactive) + require.NoError(t, err) + require.NoError(t, ss.UserAccessToken().UpdateTokenDisable(inactive.Id)) + + // Never-expiring token owned by a bot — bots are exempt and excluded. + botUser, err := ss.User().Save(rctx, model.UserFromBot(&model.Bot{Username: "noncompliant_bot", OwnerId: model.NewId()})) + require.NoError(t, err) + _, nErr := ss.Bot().Save(&model.Bot{UserId: botUser.Id, Username: botUser.Username, OwnerId: model.NewId()}) + require.NoError(t, nErr) + botToken := &model.UserAccessToken{Token: model.NewId(), UserId: botUser.Id, Description: "bot token"} + _, err = ss.UserAccessToken().Save(botToken) + require.NoError(t, err) + + // Two non-compliant tokens owned by the same user — verifies that the + // returned slice has one entry per deleted token row, not one per user. + sharedUserID := model.NewId() + multiA := &model.UserAccessToken{Token: model.NewId(), UserId: sharedUserID, Description: "multi A"} + _, err = ss.UserAccessToken().Save(multiA) + require.NoError(t, err) + multiB := &model.UserAccessToken{Token: model.NewId(), UserId: sharedUserID, Description: "multi B", ExpiresAt: now + 60*day} + _, err = ss.UserAccessToken().Save(multiB) + require.NoError(t, err) + + // Sessions minted from the non-compliant tokens — DeleteNonCompliantExpiry + // must remove these along with the tokens. + noExpirySession, nErr := ss.Session().Save(rctx, &model.Session{Token: noExpiry.Token, UserId: noExpiry.UserId}) + require.NoError(t, nErr) + farFutureSession, nErr := ss.Session().Save(rctx, &model.Session{Token: farFuture.Token, UserId: farFuture.UserId}) + require.NoError(t, nErr) + + t.Cleanup(func() { + // noExpiry, farFuture, multiA, multiB are deleted by DeleteNonCompliantExpiry; + // only surviving tokens need explicit cleanup. + _ = ss.UserAccessToken().Delete(compliant.Id) + _ = ss.UserAccessToken().Delete(inactive.Id) + _ = ss.UserAccessToken().Delete(botToken.Id) + _ = ss.Bot().PermanentDelete(botUser.Id) + _ = ss.User().PermanentDelete(rctx, botUser.Id) + }) + + // Against the 30-day cap, at least our four active non-bot violators are counted. + // Use GreaterOrEqual to avoid flakiness from concurrent tests that may also + // hold non-compliant tokens when this test runs. + count, err := ss.UserAccessToken().CountNonCompliantExpiry(maxExpiresAt) + require.NoError(t, err) + require.GreaterOrEqual(t, count, baseline30+4) + + // A non-positive limit is a no-op. + noop, err := ss.UserAccessToken().DeleteNonCompliantExpiry(maxExpiresAt, 0) + require.NoError(t, err) + require.Empty(t, noop) + + // DeleteNonCompliantExpiry deletes all violators and their sessions, returns + // one UserId per deleted token row (not per user), and leaves + // compliant/inactive/bot tokens untouched. + // Use GreaterOrEqual for the same reason as the count check above. + userIDs, err := ss.UserAccessToken().DeleteNonCompliantExpiry(maxExpiresAt, 10000) + require.NoError(t, err) + require.GreaterOrEqual(t, len(userIDs), 4, "should return at least our four deleted tokens") + // Verify sharedUserID appears exactly twice — once per token, not once per user. + // This specifically guards against a SELECT DISTINCT regression. + sharedOccurrences := 0 + gotUserIDs := map[string]bool{} + for _, id := range userIDs { + gotUserIDs[id] = true + if id == sharedUserID { + sharedOccurrences++ + } + } + require.Equal(t, 2, sharedOccurrences, "sharedUserID should appear once per deleted token, not once per user") + require.True(t, gotUserIDs[noExpiry.UserId], "user of never-expiring token should be returned") + require.True(t, gotUserIDs[farFuture.UserId], "user of far-future token should be returned") + require.True(t, gotUserIDs[sharedUserID], "shared user with multiple tokens should be returned") + require.False(t, gotUserIDs[compliant.UserId], "compliant token user must not be returned") + require.False(t, gotUserIDs[inactive.UserId], "inactive token user must not be returned") + require.False(t, gotUserIDs[botToken.UserId], "bot token user must not be returned") + + // Token rows are gone. + _, err = ss.UserAccessToken().Get(noExpiry.Id) + require.Error(t, err, "never-expiring token should be deleted") + _, err = ss.UserAccessToken().Get(farFuture.Id) + require.Error(t, err, "far-future token should be deleted") + _, err = ss.UserAccessToken().Get(multiA.Id) + require.Error(t, err, "shared-user token A should be deleted") + _, err = ss.UserAccessToken().Get(multiB.Id) + require.Error(t, err, "shared-user token B should be deleted") + + // Sessions for deleted tokens are gone. + _, nErr = ss.Session().Get(rctx, noExpirySession.Token) + require.Error(t, nErr, "session for never-expiring token should be deleted") + _, nErr = ss.Session().Get(rctx, farFutureSession.Token) + require.Error(t, nErr, "session for far-future token should be deleted") + + // Surviving tokens are untouched. + _, err = ss.UserAccessToken().Get(compliant.Id) + require.NoError(t, err, "compliant token must survive") + _, err = ss.UserAccessToken().Get(inactive.Id) + require.NoError(t, err, "inactive token must survive") + _, err = ss.UserAccessToken().Get(botToken.Id) + require.NoError(t, err, "bot token must survive") + + // Count is back to at most baseline after deletion (could be lower if our + // delete swept up tokens from other concurrent tests; could equal baseline if + // no concurrent tests hold non-compliant tokens right now). + count, err = ss.UserAccessToken().CountNonCompliantExpiry(maxExpiresAt) + require.NoError(t, err) + require.LessOrEqual(t, count, baseline30) + + // Against the much larger cap, the never-expiring tokens were the only + // violators among our fixtures; after deletion the count should not exceed + // the baseline measured before we created anything. + count, err = ss.UserAccessToken().CountNonCompliantExpiry(farCap) + require.NoError(t, err) + require.LessOrEqual(t, count, baselineFar, "count under large cap must not exceed pre-test baseline") +} diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 4afe96bce70..02f925b42f6 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -14241,6 +14241,22 @@ func (s *TimerLayerUserStore) VerifyEmail(userID string, email string) (string, return result, err } +func (s *TimerLayerUserAccessTokenStore) CountNonCompliantExpiry(maxExpiresAt int64) (int64, error) { + start := time.Now() + + result, err := s.UserAccessTokenStore.CountNonCompliantExpiry(maxExpiresAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserAccessTokenStore.CountNonCompliantExpiry", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserAccessTokenStore) Delete(tokenID string) error { start := time.Now() @@ -14289,6 +14305,22 @@ func (s *TimerLayerUserAccessTokenStore) DeleteByIds(tokenIDs []string) (int64, return result, err } +func (s *TimerLayerUserAccessTokenStore) DeleteNonCompliantExpiry(maxExpiresAt int64, limit int) ([]string, error) { + start := time.Now() + + result, err := s.UserAccessTokenStore.DeleteNonCompliantExpiry(maxExpiresAt, limit) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserAccessTokenStore.DeleteNonCompliantExpiry", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserAccessTokenStore) Get(tokenID string) (*model.UserAccessToken, error) { start := time.Now() diff --git a/server/i18n/en.json b/server/i18n/en.json index bb7f3b59512..d5d740c997b 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -9654,6 +9654,10 @@ "id": "app.user.verify_email.app_error", "translation": "Unable to update verify email field." }, + { + "id": "app.user_access_token.count_non_compliant.app_error", + "translation": "Unable to count the personal access tokens that violate the maximum lifetime policy." + }, { "id": "app.user_access_token.delete.app_error", "translation": "Unable to delete the personal access token." @@ -9690,6 +9694,14 @@ "id": "app.user_access_token.invalid_or_missing", "translation": "Invalid or missing token." }, + { + "id": "app.user_access_token.revoke_non_compliant.no_policy.app_error", + "translation": "No maximum personal access token lifetime is configured, so there are no non-compliant tokens to revoke." + }, + { + "id": "app.user_access_token.revoke_non_compliant.partial.app_error", + "translation": "The batch limit was reached before all non-compliant tokens could be revoked. Run the operation again to continue." + }, { "id": "app.user_access_token.save.app_error", "translation": "Unable to save the personal access token." diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 1f60ca13001..d9d0405ca8d 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -453,45 +453,46 @@ const ( // Users const ( - AuditEventAttachDeviceId = "attachDeviceId" // attach device IDs (standard or VoIP) to user session for mobile app - AuditEventCreateUser = "createUser" // create user account - AuditEventCreateUserAccessToken = "createUserAccessToken" // create personal access token for user API access - AuditEventDeleteUser = "deleteUser" // delete user account - AuditEventDemoteUserToGuest = "demoteUserToGuest" // demote regular user to guest account with limited permissions - AuditEventDisableUserAccessToken = "disableUserAccessToken" // disable user personal access token - AuditEventEnableUserAccessToken = "enableUserAccessToken" // enable user personal access token - AuditEventExtendSessionExpiry = "extendSessionExpiry" // extend user session expiration time - AuditEventLocalDeleteUser = "localDeleteUser" // delete user locally - AuditEventLocalPermanentDeleteAllUsers = "localPermanentDeleteAllUsers" // permanently delete all users locally - AuditEventLogin = "login" // user login to system - AuditEventLoginWithDesktopToken = "loginWithDesktopToken" // user login to system with desktop token - AuditEventLogout = "logout" // user logout from system - AuditEventMarkMessagesRead = "markAllMessagesRead" // user marked all direct and group messages as read - AuditEventMarkTeamRead = "markFullTeamRead" // user marked an entire team as read - AuditEventMigrateAuthToLdap = "migrateAuthToLdap" // migrate user authentication method to LDAP - AuditEventMigrateAuthToSaml = "migrateAuthToSaml" // migrate user authentication method to SAML - AuditEventPatchUser = "patchUser" // update user properties - AuditEventPromoteGuestToUser = "promoteGuestToUser" // promote guest account to regular user - AuditEventResetPassword = "resetPassword" // reset user password - AuditEventResetPasswordFailedAttempts = "resetPasswordFailedAttempts" // reset failed password attempt counter - AuditEventRevokeAllSessionsAllUsers = "revokeAllSessionsAllUsers" // revoke all active sessions for all users - AuditEventRevokeAllSessionsForUser = "revokeAllSessionsForUser" // revoke all active sessions for specific user - AuditEventRevokeSession = "revokeSession" // revoke specific user session - AuditEventRejectExpiredUserAccessToken = "rejectExpiredUserAccessToken" // rejected an API request because the personal access token has expired - AuditEventRevokeUserAccessToken = "revokeUserAccessToken" // revoke user personal access token - AuditEventSendPasswordReset = "sendPasswordReset" // send password reset email to user - AuditEventSendVerificationEmail = "sendVerificationEmail" // send email verification link to user - AuditEventSetDefaultProfileImage = "setDefaultProfileImage" // set user profile image to default avatar - AuditEventSetProfileImage = "setProfileImage" // set custom profile image for user - AuditEventSwitchAccountType = "switchAccountType" // switch user authentication method from one to another - AuditEventUpdatePassword = "updatePassword" // update user password - AuditEventUpdateUser = "updateUser" // update user account properties - AuditEventUpdateUserActive = "updateUserActive" // update user active status - AuditEventUpdateUserAuth = "updateUserAuth" // update user authentication method - AuditEventUpdateUserMfa = "updateUserMfa" // update user multi-factor authentication settings - AuditEventUpdateUserRoles = "updateUserRoles" // update user roles - AuditEventVerifyUserEmail = "verifyUserEmail" // verify user email address using verification token - AuditEventVerifyUserEmailWithoutToken = "verifyUserEmailWithoutToken" // verify user email address without verification token + AuditEventAttachDeviceId = "attachDeviceId" // attach device IDs (standard or VoIP) to user session for mobile app + AuditEventCreateUser = "createUser" // create user account + AuditEventCreateUserAccessToken = "createUserAccessToken" // create personal access token for user API access + AuditEventDeleteUser = "deleteUser" // delete user account + AuditEventDemoteUserToGuest = "demoteUserToGuest" // demote regular user to guest account with limited permissions + AuditEventDisableUserAccessToken = "disableUserAccessToken" // disable user personal access token + AuditEventEnableUserAccessToken = "enableUserAccessToken" // enable user personal access token + AuditEventExtendSessionExpiry = "extendSessionExpiry" // extend user session expiration time + AuditEventLocalDeleteUser = "localDeleteUser" // delete user locally + AuditEventLocalPermanentDeleteAllUsers = "localPermanentDeleteAllUsers" // permanently delete all users locally + AuditEventLogin = "login" // user login to system + AuditEventLoginWithDesktopToken = "loginWithDesktopToken" // user login to system with desktop token + AuditEventLogout = "logout" // user logout from system + AuditEventMarkMessagesRead = "markAllMessagesRead" // user marked all direct and group messages as read + AuditEventMarkTeamRead = "markFullTeamRead" // user marked an entire team as read + AuditEventMigrateAuthToLdap = "migrateAuthToLdap" // migrate user authentication method to LDAP + AuditEventMigrateAuthToSaml = "migrateAuthToSaml" // migrate user authentication method to SAML + AuditEventPatchUser = "patchUser" // update user properties + AuditEventPromoteGuestToUser = "promoteGuestToUser" // promote guest account to regular user + AuditEventResetPassword = "resetPassword" // reset user password + AuditEventResetPasswordFailedAttempts = "resetPasswordFailedAttempts" // reset failed password attempt counter + AuditEventRevokeAllSessionsAllUsers = "revokeAllSessionsAllUsers" // revoke all active sessions for all users + AuditEventRevokeAllSessionsForUser = "revokeAllSessionsForUser" // revoke all active sessions for specific user + AuditEventRevokeSession = "revokeSession" // revoke specific user session + AuditEventRejectExpiredUserAccessToken = "rejectExpiredUserAccessToken" // rejected an API request because the personal access token has expired + AuditEventRevokeUserAccessToken = "revokeUserAccessToken" // revoke user personal access token + AuditEventRevokeNonCompliantUserAccessTokens = "revokeNonCompliantUserAccessTokens" // revoke all personal access tokens that violate the maximum lifetime policy + AuditEventSendPasswordReset = "sendPasswordReset" // send password reset email to user + AuditEventSendVerificationEmail = "sendVerificationEmail" // send email verification link to user + AuditEventSetDefaultProfileImage = "setDefaultProfileImage" // set user profile image to default avatar + AuditEventSetProfileImage = "setProfileImage" // set custom profile image for user + AuditEventSwitchAccountType = "switchAccountType" // switch user authentication method from one to another + AuditEventUpdatePassword = "updatePassword" // update user password + AuditEventUpdateUser = "updateUser" // update user account properties + AuditEventUpdateUserActive = "updateUserActive" // update user active status + AuditEventUpdateUserAuth = "updateUserAuth" // update user authentication method + AuditEventUpdateUserMfa = "updateUserMfa" // update user multi-factor authentication settings + AuditEventUpdateUserRoles = "updateUserRoles" // update user roles + AuditEventVerifyUserEmail = "verifyUserEmail" // verify user email address using verification token + AuditEventVerifyUserEmailWithoutToken = "verifyUserEmailWithoutToken" // verify user email address without verification token ) // Webhooks diff --git a/server/public/model/client4.go b/server/public/model/client4.go index f03dba36b39..1ed56e292ef 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -1940,6 +1940,31 @@ func (c *Client4) GetUserAccessTokens(ctx context.Context, page int, perPage int return DecodeJSONFromResponse[[]*UserAccessToken](r) } +// GetNonCompliantUserAccessTokenCount returns the number of active personal +// access tokens that violate the configured maximum lifetime policy. It lets an +// admin preview the blast radius before revoking. Must have the 'manage_system' +// permission. +func (c *Client4) GetNonCompliantUserAccessTokenCount(ctx context.Context) (*NonCompliantUserAccessTokenResult, *Response, error) { + r, err := c.doAPIGet(ctx, c.userAccessTokensRoute().Join("non_compliant", "count"), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*NonCompliantUserAccessTokenResult](r) +} + +// RevokeNonCompliantUserAccessTokens revokes (hard-deletes) every active personal +// access token that violates the configured maximum lifetime policy and returns +// the number of tokens revoked. Must have the 'manage_system' permission. +func (c *Client4) RevokeNonCompliantUserAccessTokens(ctx context.Context) (*NonCompliantUserAccessTokenResult, *Response, error) { + r, err := c.doAPIPostJSON(ctx, c.usersRoute().Join("tokens", "non_compliant", "revoke"), nil) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*NonCompliantUserAccessTokenResult](r) +} + // GetUserAccessToken will get a user access tokens' id, description, is_active // and the user_id of the user it is for. The actual token will not be returned. // Must have the 'read_user_access_token' permission and if getting for another diff --git a/server/public/model/user_access_token.go b/server/public/model/user_access_token.go index 9acccbfeab9..964470ecd51 100644 --- a/server/public/model/user_access_token.go +++ b/server/public/model/user_access_token.go @@ -7,6 +7,13 @@ import ( "net/http" ) +// NonCompliantUserAccessTokenResult is the response payload for the endpoints +// that count or revoke personal access tokens violating the maximum lifetime +// policy. Count carries the number of tokens previewed or actually revoked. +type NonCompliantUserAccessTokenResult struct { + Count int64 `json:"count"` +} + type UserAccessToken struct { Id string `json:"id"` Token string `json:"token,omitempty"` diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 1ad28da9bfc..49329a02147 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -114,6 +114,7 @@ import PermissionSystemSchemeSettings from './permission_schemes_settings/permis import PermissionTeamSchemeSettings from './permission_schemes_settings/permission_team_scheme_settings'; import {searchableStrings as pluginManagementSearchableStrings} from './plugin_management/plugin_management'; import PushNotificationsSettings, {searchableStrings as pushSearchableStrings} from './push_settings'; +import RevokeNonCompliantTokensButton from './revoke_non_compliant_tokens_button'; import SecureConnections, {searchableStrings as secureConnectionsSearchableStrings} from './secure_connections'; import SecureConnectionDetail from './secure_connections/secure_connection_detail'; import ServerLogs from './server_logs'; @@ -6118,6 +6119,18 @@ const AdminDefinition: AdminDefinitionType = { it.stateIsFalse('ServiceSettings.EnableUserAccessTokens'), ), }, + { + type: 'custom', + key: 'RevokeNonCompliantTokensButton', + component: RevokeNonCompliantTokensButton, + showTitle: true, + label: defineMessage({id: 'admin.service.revokeNonCompliantTokensTitle', defaultMessage: 'Revoke non-compliant tokens:'}), + help_text: defineMessage({id: 'admin.service.revokeNonCompliantTokensDescription', defaultMessage: 'Permanently revokes all existing personal access tokens that do not comply with the maximum lifetime above (tokens that never expire or expire beyond the cap). The maximum lifetime only applies to newly created tokens, so use this to bring already-issued tokens into compliance. Bot account tokens are exempt. You will be shown how many tokens are affected before confirming.'}), + isDisabled: it.any( + it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.INTEGRATION_MANAGEMENT)), + it.stateIsFalse('ServiceSettings.EnableUserAccessTokens'), + ), + }, ], }, }, diff --git a/webapp/channels/src/components/admin_console/revoke_non_compliant_tokens_button/index.ts b/webapp/channels/src/components/admin_console/revoke_non_compliant_tokens_button/index.ts new file mode 100644 index 00000000000..afe3f64d231 --- /dev/null +++ b/webapp/channels/src/components/admin_console/revoke_non_compliant_tokens_button/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './revoke_non_compliant_tokens_button'; diff --git a/webapp/channels/src/components/admin_console/revoke_non_compliant_tokens_button/revoke_non_compliant_tokens_button.tsx b/webapp/channels/src/components/admin_console/revoke_non_compliant_tokens_button/revoke_non_compliant_tokens_button.tsx new file mode 100644 index 00000000000..eae0e9f2527 --- /dev/null +++ b/webapp/channels/src/components/admin_console/revoke_non_compliant_tokens_button/revoke_non_compliant_tokens_button.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {Client4} from 'mattermost-redux/client'; + +import AlertBanner from 'components/alert_banner'; +import ConfirmModal from 'components/confirm_modal'; + +type SaveAction = () => Promise<{error?: {message?: string}}>; + +type Props = { + + // True when the surrounding setting is read-only for this admin. + disabled?: boolean; + + // System Console save-action hooks. Registered actions run after the config + // has been persisted, so we use one to refresh the count once the admin + // saves a new policy. + registerSaveAction?: (saveAction: SaveAction) => void; + unRegisterSaveAction?: (saveAction: SaveAction) => void; +}; + +// RevokeNonCompliantTokensButton lets an admin bulk-revoke every personal access +// token that violates the configured MaximumPersonalAccessTokenLifetimeDays +// policy. The non-compliant count comes from the server (the source of truth for +// the persisted policy) and is shown up front, refreshed after a save, and after +// a revoke, so the blast radius is visible without clicking and the button is +// disabled when there is nothing to revoke. The remaining click always confirms +// the irreversible hard-delete first, satisfying MM-69075. +const RevokeNonCompliantTokensButton = ({disabled, registerSaveAction, unRegisterSaveAction}: Props) => { + const [showConfirm, setShowConfirm] = useState(false); + const [busy, setBusy] = useState(false); + + // null = not yet loaded; a number once the count has been fetched. + const [count, setCount] = useState(null); + const [error, setError] = useState(''); + const [revokedCount, setRevokedCount] = useState(null); + + const refreshCount = useCallback(async () => { + try { + const {count: nonCompliant} = await Client4.getNonCompliantUserAccessTokenCount(); + setCount(nonCompliant); + setError(''); + } catch (err) { + setError(err instanceof Error ? err.message : ''); + } + }, []); + + // Load the count when the setting renders, and register a save action so it + // refreshes right after the admin saves a new policy (patchConfig has + // already persisted by the time save actions run, so the server returns the + // count for the new policy). + useEffect(() => { + refreshCount(); + + const saveAction: SaveAction = async () => { + setRevokedCount(null); + await refreshCount(); + return {}; + }; + registerSaveAction?.(saveAction); + return () => unRegisterSaveAction?.(saveAction); + }, [refreshCount, registerSaveAction, unRegisterSaveAction]); + + const handleConfirm = useCallback(async () => { + setBusy(true); + try { + const {count: revoked} = await Client4.revokeNonCompliantUserAccessTokens(); + setRevokedCount(revoked); + setCount(0); + setError(''); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + setShowConfirm(false); + } + }, []); + + const openConfirm = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setError(''); + setRevokedCount(null); + setShowConfirm(true); + }, []); + + const handleCancel = useCallback(() => setShowConfirm(false), []); + + const nothingToRevoke = count === null || count === 0; + + return ( +
+ + {error && ( + + )} + /> + )} + {!error && revokedCount !== null && ( + + )} + /> + )} + {!error && revokedCount === null && count !== null && ( + 0 ? 'danger' : 'success'} + message={count > 0 ? ( + + ) : ( + + )} + /> + )} + + )} + message={( + + )} + confirmButtonVariant='destructive' + confirmButtonText={( + + )} + onConfirm={handleConfirm} + onCancel={handleCancel} + /> +
+ ); +}; + +export default React.memo(RevokeNonCompliantTokensButton); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index eed7edf029d..4ab55322eee 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3080,6 +3080,16 @@ "admin.service.personalAccessTokenMaxLifetimeTitle": "Maximum Personal Access Token Lifetime (days):", "admin.service.readTimeout": "Read Timeout:", "admin.service.readTimeoutDescription": "Maximum time allowed from when the connection is accepted to when the request body is fully read.", + "admin.service.revokeNonCompliantTokens.button": "Revoke non-compliant tokens", + "admin.service.revokeNonCompliantTokens.confirmBody": "This will permanently revoke {count, number} personal access {count, plural, one {token} other {tokens}} that {count, plural, one {does} other {do}} not comply with the current maximum lifetime policy. This cannot be undone. Bot account tokens are not affected.", + "admin.service.revokeNonCompliantTokens.confirmButton": "Revoke tokens", + "admin.service.revokeNonCompliantTokens.confirmTitle": "Revoke non-compliant personal access tokens?", + "admin.service.revokeNonCompliantTokens.count": "{count, number} personal access {count, plural, one {token} other {tokens}} currently {count, plural, one {violates} other {violate}} the maximum lifetime policy.", + "admin.service.revokeNonCompliantTokens.error": "Unable to revoke non-compliant tokens: {error}", + "admin.service.revokeNonCompliantTokens.none": "No personal access tokens currently need to be revoked.", + "admin.service.revokeNonCompliantTokens.success": "Revoked {count, number} non-compliant personal access {count, plural, one {token} other {tokens}}.", + "admin.service.revokeNonCompliantTokensDescription": "Permanently revokes all existing personal access tokens that do not comply with the maximum lifetime above (tokens that never expire or expire beyond the cap). The maximum lifetime only applies to newly created tokens, so use this to bring already-issued tokens into compliance. Bot account tokens are exempt. You will be shown how many tokens are affected before confirming.", + "admin.service.revokeNonCompliantTokensTitle": "Revoke non-compliant tokens:", "admin.service.sessionCache": "Session Cache (minutes):", "admin.service.sessionCacheDesc": "The number of minutes to cache a session in memory:", "admin.service.sessionHoursEx": "E.g.: \"720\"", diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 17ec8158422..0e483221ee7 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -1324,6 +1324,20 @@ export default class Client4 { ); }; + getNonCompliantUserAccessTokenCount = () => { + return this.doFetch<{count: number}>( + `${this.getUsersRoute()}/tokens/non_compliant/count`, + {method: 'get'}, + ); + }; + + revokeNonCompliantUserAccessTokens = () => { + return this.doFetch<{count: number}>( + `${this.getUsersRoute()}/tokens/non_compliant/revoke`, + {method: 'post'}, + ); + }; + disableUserAccessToken = (tokenId: string) => { return this.doFetch( `${this.getUsersRoute()}/tokens/disable`,