mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-03 09:53:10 -05:00
[MM-69075] Revoke non-compliant personal access tokens (#37030)
* [MM-68421] Frontend: PAT creation UI — expiry picker and status display
Wires the webapp UI to the server-side support added in PR #36706 (and
the MM-68419 model changes):
- Extends UserAccessToken type with expires_at, and ClientConfig with
EnforcePersonalAccessTokenExpiry / MaximumPersonalAccessTokenLifetimeDays.
- Threads expires_at through Client4.createUserAccessToken and the
mattermost-redux createUserAccessToken action.
- Adds a date/time picker to the PAT creation form (reuses
DateTimePickerModal). Honors enforcement and clamps to the
max-lifetime setting; maps server error ids
(expires_at_required/in_past/too_far) to localized messages.
- Displays expiry, derived status badge (active/expired/disabled), and
an approaching-expiry warning (<7 days) in the account settings token
list. Mirrors expiry + status in the admin Manage Tokens modal.
- Adds the supporting i18n keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Address review: i18n, ServerError type, scss, explicit guards
- Inject intl so the "Expires in N days" tooltip and the picker
ariaLabel are localized instead of hard-coded English.
- Use the canonical ServerError type from @mattermost/types/errors
instead of an ad-hoc inline cast.
- Dedupe the new i18n ids — keep a single "Expires: " string per
namespace and drop the duplicates introduced in the first pass.
- Add scss for setting-box__token-expiry / __token-status /
__token-expiry-warning so the new status pill and warning render
with the expected styling.
- Replace truthy checks on the numeric expiresAt with explicit
> 0 comparisons for readability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Replace expiry datetime picker with preset chooser + custom date
Per UX discussion: PATs are long-lived so sub-day precision is noise.
Swap the DateTimePickerModal for a native <select> of presets
(No expiry, 7d, 30d, 90d, 1 year, Custom date) with a date-only
<input type="date"> revealed when Custom is selected. Effective time
is end-of-local-day, which matches how users think about expiry.
- Drops the DateTimePickerModal import and the picker open/close
handlers; removes the moment-timezone import.
- Adds isPresetAllowed() that hides presets exceeding
MaximumPersonalAccessTokenLifetimeDays, and a defaultExpiryPreset()
that picks 30d (then 7d, then Custom) when enforcement is on,
No expiry otherwise.
- The custom <input type="date"> uses min=today and max=now+maxDays
for native bounds; submit-time validation still maps server error
ids if the user bypasses the bounds.
- i18n: adds preset labels; drops the now-unused picker/clear/change
strings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Fix CI: playwright config and ESLint blank line
- Add EnforcePersonalAccessTokenExpiry / MaximumPersonalAccessTokenLifetimeDays
to e2e-tests/playwright/lib/src/server/default_config.ts so its tsc -b
matches the updated ServiceSettings shape.
- Drop a stray double blank line in user_access_token_section.tsx that
tripped no-multiple-empty-lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Sort i18n keys to match formatjs extract output
ci/i18n-extract diffs en.json against the formatjs extractor's sorted
output and fails on any difference. Re-run the extract so the new
PAT-expiry keys land in alphabetical position.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Address CodeRabbit findings
- Clamp the default custom-expiry date to maxLifetimeDays when set
so the form doesn't open in an invalid state when
defaultExpiryPreset() falls back to 'custom'.
- Reject a cleared/empty custom date with expires_at_required
instead of silently submitting without an expiry; only forward
expiresAt to the action when it's > 0.
- Use an explicit undefined check in Client4.createUserAccessToken
so an intentional 0 isn't dropped from the request body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Update manage_tokens_modal snapshot for expiry + status row
The admin token list now renders an Expires row and a status badge per
token; refresh the jest snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Add unit tests for PAT expiry helpers
Per the PR Test Analysis advisory: export the pure helpers from
user_access_token_section.tsx and add unit tests covering them.
Coverage:
- deriveTokenStatus: active / expired / inactive branches.
- mapServerErrorIdToMessage: all three server error ids (short and
api.user.create_user_access_token.*.app_error variants) and the
default null path.
- endOfLocalDayPlusDays: end-of-day on the Nth future day, 0 days.
- endOfLocalDayFromIsoDate: valid ISO date and malformed inputs.
- PRESET_DAYS: snapshot of the preset durations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Freeze time in PAT helper tests to avoid midnight flake
The date arithmetic helpers (Date.now, new Date()) could disagree
across a midnight boundary, making the tests theoretically flaky.
Pin system time to a stable mid-day in 2026 via jest.useFakeTimers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [MM-68421] Add component tests for PAT expiry creation UI
Covers the create-form validation branches (missing description, empty
custom date, past date, beyond maxLifetimeDays), enforceExpiry rendering
(no-expiry option hidden + enforced hint), maxLifetimeDays preset
filtering, and token-list status display (active/expired/disabled, never,
and the "expires soon" warning).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Add behavioral status/expiry tests for manage_tokens_modal
Covers the admin token modal's derived status display: Active + "Never"
for an active token without expiry, Expired for an active token past its
expiry, Disabled for an inactive token regardless of expiry, and Active
with a rendered date (not "Never") for a token expiring in the future.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Align PAT expiry UI with shipped server contract
The server (#36706) did not ship a separate EnforcePersonalAccessTokenExpiry
flag — expiry enforcement is implied by MaximumPersonalAccessTokenLifetimeDays
> 0. Two webapp gaps surfaced once the server side merged:
- enforceExpiry was read from the never-sent config.EnforcePersonalAccessToken
Expiry, so the "hide No-expiry / require expiry" path was dead. Derive it from
maxLifetimeDays > 0 instead and drop the dead config flag from the component,
redux props, ClientConfig/ServiceSettings types, and the e2e default config.
- The server returns app.user_access_token.expires_at_{required,in_past,too_far}
.app_error, but mapServerErrorIdToMessage matched the api.user.create_user_
access_token.* namespace, so the localized errors never fired. Map the actual
ids.
Unit tests updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Add Playwright e2e for PAT expiry UI
Covers, against a real server, the personal access token expiry surfaces in
Account Settings > Security:
- the expiry picker renders all presets and reveals the custom-date input
- a custom expiry with no date is blocked client-side
- MaximumPersonalAccessTokenLifetimeDays > 0 hides "No expiry" and oversized
presets, shows the enforced hint, and rejects an over-the-limit custom date
- the token list shows Active/Never, an "expires in N days" warning, and the
Disabled badge (seeded via the API)
The expired-status badge is left to the component unit tests since the server
rejects creating a token whose expiry is already in the past.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Use locator('option') for native select assertions
getByRole('option') does not reliably match the options of a closed native
<select>, which would make the absence assertions (toHaveCount(0)) pass
vacuously. Query option elements by DOM instead, matching the repo's house
pattern for native selects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Fix expiry overshoot vs server cap and review nits
Review found that presets/custom dates resolve to end-of-local-day, which can
sit up to ~24h beyond the server cap of "now + MaximumPersonalAccessTokenLife
timeDays" (an exact duration from creation time). With a max configured, the
default preset equals the cap, so accepting the default and saving was rejected
server-side with expires_at_too_far for most of the day.
- Clamp the submitted expiry to the server cap when a maximum lifetime is set.
Validation still runs on the raw end-of-day value so an explicitly out-of-range
custom date is still rejected; only the in-range end-of-day overshoot is clamped.
- Count "expires in N days" from the start of today and floor it, so an end-of-day
expiry no longer over-reports by one (a 7-day token reads "7 days", not "8").
- Add an aria-label to the custom expiry date input.
Tests: unit coverage for clampExpiresAtToMaxLifetime; a Playwright spec that
creates a token with the default preset under a 30-day cap and asserts success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Apply prettier formatting to PAT e2e spec
The ci/playwright/npm-check job (lint + prettier + tsc + lint:test-docs) failed
on prettier formatting. eslint and tsc were clean; reformat the spec to satisfy
prettier as well.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Add PAT max-lifetime System Console setting and creation-form UX fixes
- Add ServiceSettings.MaximumPersonalAccessTokenLifetimeDays number field to
System Console > Integrations > Integration Management, after Enable Personal
Access Tokens (disabled when tokens are disabled).
- Disable the token creation Save button until a non-empty description is
entered (description input is now controlled; whitespace-only rejected).
- Render the token creation form as a distinct "Create New Token" card so it no
longer blends into the existing token list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix stylelint property order in new-token card
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* [MM-68421] Validate PAT expiry inline before submit
Surface the expiry validation error in the create-token form and disable
Save while the selection is invalid, instead of only failing inside the
create-confirmation flow. Previously a system admin had to click Save then
"Yes, Create" before seeing "An expiry date is required." for an empty
custom date.
Extracts the expiry checks into getExpiryValidationError(), reuses it as
the handleCreateToken guard, and renders the result inline + in the Save
button's disabled condition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [MM-68421] Fix PAT expiry e2e specs for inline validation
The "blocks submitting a custom expiry with no date chosen" and "enforces
expiry when a maximum lifetime is configured" specs clicked the Save button
and expected an inline error afterward. Since 7ccd65ea surfaces the expiry
error inline and disables Save while the selection is invalid, the click
timed out on a disabled button.
Assert the inline error is visible and that Save is disabled, instead of
clicking it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* MM-69075: server-side revoke non-compliant PATs
Adds store queries (GetNonCompliantExpiry/CountNonCompliantExpiry),
app methods to count and bulk-revoke (hard-delete) non-compliant PATs in
batches with session-cache invalidation, sysadmin-gated api4 endpoints
with audit logging, Client4 methods, and the OpenAPI spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* MM-69075: System Console button to revoke non-compliant PATs
Adds a 'Revoke non-compliant tokens' control under Integrations >
Integration Management (next to Maximum Personal Access Token Lifetime).
It shows the current non-compliant count (refreshed on load, save, and
revoke), disables when there is nothing to revoke, and confirms the
irreversible delete with the blast radius. Wires up the TS Client4
methods and i18n strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* MM-69075: Address PR review feedback
- Rename route from /tokens/revoke_non_compliant to /tokens/non_compliant/revoke for consistency with /tokens/non_compliant/count
- Remove redundant c.LogAudit("") before permission check
- Remove redundant c.LogAudit on success (structured audit record is sufficient)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Simplify non-compliant token revocation with single store call
- Add DeleteNonCompliantExpiry store method: atomically deletes non-compliant
tokens and their sessions in a single Postgres CTE, returning affected user
IDs for session cache clearing
- Replace the get->extract IDs->delete dance in RevokeNonCompliantUserAccessTokens
with the new single store call per batch
- Switch post-loop partial completion check to CountNonCompliantExpiry
- Add partial completion error i18n string
- Clear stale error banner in refreshCount on successful fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Remove GetNonCompliantExpiry, migrate tests to DeleteNonCompliantExpiry
GetNonCompliantExpiry is now unused — DeleteNonCompliantExpiry supersedes it.
Remove it from the store interface, sqlstore, retrylayer, timerlayer, and mock.
Migrate the store test to exercise DeleteNonCompliantExpiry instead, adding
session-deletion verification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Fix DISTINCT undercounting bug and add multi-token-per-user test
- Remove SELECT DISTINCT from DeleteNonCompliantExpiry CTE so each deleted
token row is returned, not collapsed per user; totalRevoked now counts
tokens, not users, and batch-continuation is correct
- Deduplicate userIDs in app layer before ClearSessionCacheForUser calls
- Add multi-token-per-user fixture to store test: two non-compliant tokens
sharing a UserId verify len(userIDs)==4 and catch any future DISTINCT regression
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Fix generated store layers and mock ordering
Regenerate retrylayer/timerlayer via make store-layers and fix
DeleteNonCompliantExpiry alphabetical position in mock file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Inline nonCompliantExpiryWhere into its sole caller
The helper was extracted to share the predicate between GetNonCompliantExpiry
and CountNonCompliantExpiry. GetNonCompliantExpiry is gone; with a single
call site the extracted function adds no value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Fix flaky test — relax global count assertions
Global count assertions (require.Equal against baseline) are fragile when
other concurrent tests hold non-compliant tokens. Replace exact equality
with GreaterOrEqual/LessOrEqual for global counts. The DISTINCT regression
is still caught precisely: sharedUserID must appear exactly twice in the
returned slice (once per token, not once per user).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Fix false partial-revoke error caused by read-replica lag
After DeleteNonCompliantExpiry writes to master, the post-delete
CountNonCompliantExpiry read targets the replica, which may not have
caught up yet. This made the first revoke call return a spurious HTTP
500 even though all tokens were actually deleted, and a second click
then showed 'Revoked 0' because nothing remained.
Fix: track whether the batch loop exited via a natural break (all done)
vs. exhausted revokeNonCompliantMaxBatches (genuinely incomplete), and
signal the partial-revoke error only in the latter case. This removes
the racy replica read entirely — the loop's own exit conditions prove
completion on master.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* MM-69075: Add Playwright e2e coverage for revoke non-compliant tokens UI
Covers the gap flagged in PR review (#37030): button/disabled states,
AlertBanner states, confirmation modal open/cancel/confirm, count
refresh after policy save, and token auth invalidation (compliant and
bot tokens survive a revoke).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* MM-69075: Fix testid for number-type Maximum PAT Lifetime field
Number-type TextSetting inputs use ${id}number as their test id, not
${id}input (only text-type inputs get the 'input' suffix). Found by
actually running the spec locally against a server built from this
branch - all 4 tests now pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* MM-69075: Fix prettier formatting in e2e spec
CI's prettier --check flagged this file; ran prettier --write to match
project style. The other CI lint warnings (max-lines, no-warning-comments)
are pre-existing, in files this branch never touched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* MM-69075: Fix TS2345 - UserAccessToken.token is optional
The token secret field is only populated on the object returned from
CreateUserAccessToken, so its type is string | undefined. Guard for
that in tokenIsUsable instead of asserting non-null at every call site.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1d3bbc638b
commit
ef60931ab9
@@ -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:
|
||||
|
||||
+209
@@ -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<boolean> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+4
@@ -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';
|
||||
+180
@@ -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<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [revokedCount, setRevokedCount] = useState<number | null>(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 (
|
||||
<div
|
||||
className='RevokeNonCompliantTokensButton'
|
||||
style={{display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '8px'}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-tertiary'
|
||||
disabled={disabled || busy || nothingToRevoke}
|
||||
onClick={openConfirm}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.button'
|
||||
defaultMessage='Revoke non-compliant tokens'
|
||||
/>
|
||||
</button>
|
||||
{error && (
|
||||
<AlertBanner
|
||||
mode='danger'
|
||||
message={(
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.error'
|
||||
defaultMessage='Unable to revoke non-compliant tokens: {error}'
|
||||
values={{error}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!error && revokedCount !== null && (
|
||||
<AlertBanner
|
||||
mode='success'
|
||||
message={(
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.success'
|
||||
defaultMessage='Revoked {count, number} non-compliant personal access {count, plural, one {token} other {tokens}}.'
|
||||
values={{count: revokedCount}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!error && revokedCount === null && count !== null && (
|
||||
<AlertBanner
|
||||
mode={count > 0 ? 'danger' : 'success'}
|
||||
message={count > 0 ? (
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.count'
|
||||
defaultMessage='{count, number} personal access {count, plural, one {token} other {tokens}} currently {count, plural, one {violates} other {violate}} the maximum lifetime policy.'
|
||||
values={{count}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.none'
|
||||
defaultMessage='No personal access tokens currently need to be revoked.'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal
|
||||
show={showConfirm}
|
||||
title={(
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.confirmTitle'
|
||||
defaultMessage='Revoke non-compliant personal access tokens?'
|
||||
/>
|
||||
)}
|
||||
message={(
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.confirmBody'
|
||||
defaultMessage='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.'
|
||||
values={{count: count ?? 0}}
|
||||
/>
|
||||
)}
|
||||
confirmButtonVariant='destructive'
|
||||
confirmButtonText={(
|
||||
<FormattedMessage
|
||||
id='admin.service.revokeNonCompliantTokens.confirmButton'
|
||||
defaultMessage='Revoke tokens'
|
||||
/>
|
||||
)}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(RevokeNonCompliantTokensButton);
|
||||
@@ -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\"",
|
||||
|
||||
@@ -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<StatusOK>(
|
||||
`${this.getUsersRoute()}/tokens/disable`,
|
||||
|
||||
Reference in New Issue
Block a user