snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, expectFilesRedacted, getRandomId, testConfig} from '@mattermost/playwright-lib';
import {createPermissionPolicy, deletePermissionPolicyByName, navigateToPermissionPoliciesPage} from '../support';
import {ensureABACEnabled, setupUserAndChannel, waitForPolicy} from './helpers';
// Each surface renders attachments through a different React tree, so one that forgets to
// honour RedactedFileCount is invisible to server-side coverage.
//
// Denies via a comparison no test user satisfies: policy creation rejects a bare `false`
// literal, and these users have no Department value.
const DENY_ALL_CEL = "user.attributes.Department == 'no-such-value-deny-all'";
const THREAD_ROOT_MESSAGE = 'thread root with file';
test.describe('ABAC file permissions - redaction across surfaces', () => {
let lastPolicyName = '';
let savedAdminClient: any = null;
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
});
test.afterEach(async () => {
if (lastPolicyName && savedAdminClient) {
await deletePermissionPolicyByName(savedAdminClient, lastPolicyName);
lastPolicyName = '';
savedAdminClient = null;
}
});
/**
* @objective Verify a Burn-on-Read message's attachment is redacted after the recipient
* reveals it, so the reveal cannot be used to sidestep the download policy.
*/
test(
'MM-T5827 redacts the attachment of a revealed Burn-on-Read message',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
// # Admin sends a Burn-on-Read message carrying a file
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.toggleBurnOnRead();
await adminChannelsPage.centerView.postCreate.postMessage('BOR with file', ['sample_text_file.txt']);
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `BOR Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user opens the channel and reveals the concealed message
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
const post = await channelsPage.centerView.getLastPost();
await post.concealedPlaceholder.toBeVisible();
await post.concealedPlaceholder.clickToReveal();
await post.concealedPlaceholder.waitForReveal(pw.duration.ten_sec);
// * Verify the revealed message's file is redacted
await post.toHaveFilesRedacted();
},
);
/**
* @objective Verify the file inside an embedded permalink preview is redacted, not just the
* file on the original post — the preview is built from a separately fetched post.
*/
test(
'MM-T5828 redacts the attachment inside an embedded permalink preview',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName, channelId} = await setupUserAndChannel(adminClient, team);
// # Admin posts a file, then posts a permalink to it in the same channel
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage('original with file', ['sample_text_file.txt']);
const originalPosts = await adminClient.getPosts(channelId, 0, 1);
const originalPostId = originalPosts.order[0];
// Use testConfig.internalBaseURL rather than the admin client's host-mapped route:
// the server must recognize this URL as its own SiteURL to embed it through an
// internal permalink lookup, instead of fetching it back over HTTP as a link
// (which fails under testcontainers).
const permalinkUrl = `${testConfig.internalBaseURL}/${team.name}/pl/${originalPostId}`;
await adminChannelsPage.centerView.postCreate.postMessage(permalinkUrl);
// Both posts are addressed by ID: the embed repeats the original's text, so filtering
// by text cannot tell the two of them apart.
const permalinkPosts = await adminClient.getPosts(channelId, 0, 1);
const permalinkPostId = permalinkPosts.order[0];
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `Permalink Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user opens the channel
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the standalone original post is redacted
const originalPost = await channelsPage.centerView.getPostById(originalPostId);
await originalPost.toHaveFilesRedacted();
// * Verify the embedded preview is redacted too, and not merely file-less
const permalinkPost = await channelsPage.centerView.getPostById(permalinkPostId);
await expect(permalinkPost.postPreview).toBeVisible();
await permalinkPost.toHaveFilesRedacted(permalinkPost.postPreview);
},
);
/**
* @objective Verify a denied user sees the redacted placeholder when the same post is opened
* in a thread in the right-hand sidebar, which renders through its own post list.
*/
test(
'redacts the attachment when the post is opened in a thread',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
// # Admin posts a file
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(THREAD_ROOT_MESSAGE, ['sample_text_file.txt']);
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `Thread Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user opens the post in a thread
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
const centerPost = await channelsPage.centerView.getPostByText(THREAD_ROOT_MESSAGE);
await centerPost.toHaveFilesRedacted();
await centerPost.openAThread();
await channelsPage.sidebarRight.toBeVisible();
// * Verify the thread's copy of the post is redacted as well
const rhsPost = await channelsPage.sidebarRight.getFirstPost();
await rhsPost.toHaveFilesRedacted();
},
);
/**
* @objective Verify a denied user sees the redacted placeholder in search results, which
* render posts through the search panel rather than a channel post list.
*/
test('redacts the attachment in search results', {tag: '@abac_file_permissions'}, async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
// # Admin posts a file with a searchable, unique term
const searchTerm = `redactme${getRandomId()}`;
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(searchTerm, ['sample_text_file.txt']);
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `Search Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user searches for the post
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
await channelsPage.searchFor(searchTerm);
// * Verify the search result shows the redacted placeholder, not the file
const result = channelsPage.searchResultsPanel.getResultByText(searchTerm).first();
await expect(result).toBeVisible();
await expectFilesRedacted(result);
});
});

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, test, enableABAC, getAdminClient, getRandomId, TestBrowser} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValuesForUser,
} from '../../../channels/custom_profile_attributes/helpers';
import {
createPermissionPolicy,
createPrivateChannelForABAC,
createUserForABAC,
deletePermissionPolicyByName,
enableUserManagedAttributes,
navigateToPermissionPoliciesPage,
updatePermissionPolicyExpression,
} from '../support';
import {ensureABACEnabled, waitForPolicy} from './helpers';
// Each test adds a member, and each addition appends a join system message after the fixture
// post, so the post under test is never the last one.
const FIXTURE_MESSAGE = 'File for live tests';
const GRANTING_EXPRESSION = 'user.attributes.Department == "Engineering"';
const REVOKING_EXPRESSION = 'user.attributes.Department == "Finance"';
// Access changes reaching an already-open page. Two triggers, two websocket events: an
// attribute change sends custom_profile_attributes_values_updated, a policy edit sends
// permission_policy_updated. Each test creates its own user so a revoke cannot leak.
test.describe('ABAC file permissions - live updates', () => {
let sharedAdminClient: Client4;
let policyName = '';
let team: any;
let channelName = '';
let channelId = '';
let attributeFieldsMap: Record<string, any>;
let licensed = true;
let sharedBrowser: TestBrowser | null = null;
test.beforeAll(async ({browser}) => {
test.setTimeout(240000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found — cannot proceed with ABAC file-access tests');
}
sharedAdminClient = adminClient;
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await enableUserManagedAttributes(adminClient);
const departmentAttr: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttr);
const suffix = getRandomId();
team = await adminClient.createTeam({
name: `abac-live-${suffix}`,
display_name: `ABAC Live ${suffix}`,
type: 'O',
} as any);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
channelName = channel.name;
channelId = channel.id;
sharedBrowser = new TestBrowser(browser);
// ABAC off while the admin posts the fixture file, so a policy left behind by an
// earlier spec cannot block the upload this whole describe depends on.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
} as any);
const {channelsPage: adminChannelsPage} = await sharedBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(FIXTURE_MESSAGE, ['sample_text_file.txt']);
const {systemConsolePage} = await sharedBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
policyName = `Dept Live Policy ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: GRANTING_EXPRESSION,
permissions: ['Upload Files', 'Download Files'],
adminClient,
});
await waitForPolicy(adminClient, policyName);
});
test.beforeEach(async () => {
test.skip(!licensed, 'No ABAC license');
await ensureABACEnabled(sharedAdminClient);
});
test.afterAll(async () => {
if (policyName && sharedAdminClient) {
await deletePermissionPolicyByName(sharedAdminClient, policyName).catch(() => {});
}
await sharedBrowser?.close().catch(() => {});
});
/**
* Create a user who currently satisfies the policy and drop them into the fixture channel.
* Each test gets its own so that revoking access is not visible to the others.
*/
async function createAllowedMember() {
const user = await createUserForABAC(sharedAdminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
await sharedAdminClient.addToTeam(team.id, user.id);
await sharedAdminClient.addToChannel(user.id, channelId);
return user;
}
/** Move a user out of the department the policy grants access to. */
async function revokeByAttribute(userId: string) {
await setupCustomProfileAttributeValuesForUser(
sharedAdminClient,
[{name: 'Department', type: 'text', value: 'Sales'}],
attributeFieldsMap,
userId,
);
}
/**
* @objective Verify the upload control flips from enabled to disabled on an open page when
* the user's attributes stop satisfying the policy, without a reload.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'disables the upload control on an open page when the user attribute changes',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who currently matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify upload starts out available
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
// # Admin moves the user out of the granted department
await revokeByAttribute(user.id);
// * Verify the control becomes unavailable without the page being reloaded
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
},
);
/**
* @objective Verify file attachments already rendered on screen are replaced by the redacted
* placeholder when the user's attributes stop satisfying the policy, without a reload.
*
* @precondition
* A licensed server with a permission policy granting Download Files to Department == "Engineering"
*/
test(
'redacts files already on screen when the user attribute changes',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who currently matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the file starts out visible
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesVisible();
// # Admin moves the user out of the granted department
await revokeByAttribute(user.id);
// * Verify the already-loaded post is re-rendered as redacted, with no reload
await post.toHaveFilesRedacted();
},
);
/**
* @objective Verify a revoked user stays revoked across a reload — the post-list ETag epoch
* moved, so the refetch cannot be served from a cache populated while access was allowed.
*
* @precondition
* A licensed server with a permission policy granting both file permissions to Department == "Engineering"
*/
test(
'keeps files redacted and upload blocked after a page reload',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who currently matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify access starts out granted
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesVisible();
// # Admin revokes access, then the user reloads
await revokeByAttribute(user.id);
await page.reload();
await channelsPage.toBeVisible();
// * Verify the fresh fetch is sanitized rather than served from the stale cache
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
const reloadedPost = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await reloadedPost.toHaveFilesRedacted();
},
);
/**
* @objective Verify editing the policy itself — rather than the user's attributes — reaches
* an already-open page and withdraws the upload affordance.
*
* Distinct path from the tests above: a policy edit is broadcast system-wide as
* permission_policy_updated, with no per-user event to key off.
*
* Revokes by narrowing the rule's expression, not by removing the action. An action that no
* rule grants is implicitly allowed, so dropping it from the policy would widen access.
*
* @precondition
* A licensed server with a permission policy granting both file permissions to Department == "Engineering"
*/
test(
'disables the upload control on an open page when the policy stops matching the user',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify upload starts out available
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
// # Restore the fixture even if the assertion below fails, so the shared policy does
// not leak a revoking expression into the tests that run after this one.
try {
// # Admin repoints the policy at a department the user is not in, leaving the
// user's own attributes untouched
await updatePermissionPolicyExpression(sharedAdminClient, policyName, REVOKING_EXPRESSION);
// * Verify the upload affordance is withdrawn without the page being reloaded
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
} finally {
await updatePermissionPolicyExpression(sharedAdminClient, policyName, GRANTING_EXPRESSION);
}
},
);
});

View File

@@ -0,0 +1,365 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, test, enableABAC, getAdminClient, getRandomId, TestBrowser} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
cleanupAllPermissionPolicies,
createPermissionPolicy,
createPrivateChannelForABAC,
createUserForABAC,
deletePermissionPolicyByName,
enableUserManagedAttributes,
navigateToPermissionPoliciesPage,
} from '../support';
import {ensureABACEnabled, setupUserAndChannel, waitForPolicy} from './helpers';
// Targeted by text, not position: joining a channel appends a system message and later tests
// append their own posts, so the fixture post is not the last one.
const FIXTURE_MESSAGE = 'File for render tests';
const NO_POLICY_MESSAGE = 'File attachment post';
// Load-time assertions only. Changes to an already-open page live in
// file_permissions_live_updates.spec.ts; non-center-channel surfaces in
// file_permissions_edge_cases.spec.ts.
test.describe('ABAC file permissions - attribute-based policy', () => {
let sharedAdminClient: Client4;
let policyName = '';
let team: any;
let channelName = '';
let allowedUser: any;
let deniedUser: any;
let licensed = true;
let sharedBrowser: TestBrowser | null = null;
test.beforeAll(async ({browser}) => {
test.setTimeout(240000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found — cannot proceed with ABAC file-access tests');
}
sharedAdminClient = adminClient;
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await enableUserManagedAttributes(adminClient);
const departmentAttr: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttr);
allowedUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
deniedUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
const suffix = getRandomId();
team = await adminClient.createTeam({
name: `abac-render-${suffix}`,
display_name: `ABAC Render ${suffix}`,
type: 'O',
} as any);
await adminClient.addToTeam(team.id, allowedUser.id);
await adminClient.addToTeam(team.id, deniedUser.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
channelName = channel.name;
await adminClient.addToChannel(allowedUser.id, channel.id);
await adminClient.addToChannel(deniedUser.id, channel.id);
sharedBrowser = new TestBrowser(browser);
// Turn ABAC off while the admin posts the fixture file, so a policy left behind by an
// earlier spec cannot block the upload this whole describe depends on.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
} as any);
const {channelsPage: adminChannelsPage} = await sharedBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(FIXTURE_MESSAGE, ['sample_text_file.txt']);
const {systemConsolePage} = await sharedBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
policyName = `Dept Render Policy ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering"',
permissions: ['Upload Files', 'Download Files'],
adminClient,
});
await waitForPolicy(adminClient, policyName);
});
test.beforeEach(async () => {
test.skip(!licensed, 'No ABAC license');
await ensureABACEnabled(sharedAdminClient);
});
test.afterAll(async () => {
if (policyName && sharedAdminClient) {
await deletePermissionPolicyByName(sharedAdminClient, policyName).catch(() => {});
}
await sharedBrowser?.close().catch(() => {});
});
/**
* @objective Verify a user whose attributes satisfy the policy sees real file attachments.
*
* @precondition
* A licensed server with a permission policy granting Download Files to Department == "Engineering"
*/
test(
'MM-T5826_b shows file attachments to a user whose attributes match the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department matches the policy
const {channelsPage} = await pw.testBrowser.login(allowedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the post renders its real attachments, not the redacted placeholder
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesVisible();
},
);
/**
* @objective Verify a user whose attributes fail the policy sees the redacted placeholder
* instead of the file.
*
* @precondition
* A licensed server with a permission policy granting Download Files to Department == "Engineering"
*/
test(
'MM-T5826_a redacts file attachments for a user whose attributes fail the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department does not match the policy
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the file is replaced by the redacted placeholder
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesRedacted();
await expect(post.redactedFilesPlaceholder).toContainText('Files not available');
},
);
/**
* @objective Verify the upload affordance is offered to a user whose attributes satisfy the policy.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'enables the upload control for a user whose attributes match the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department matches the policy
const {channelsPage} = await pw.testBrowser.login(allowedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the attachment control is offered
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
},
);
/**
* @objective Verify the upload affordance is disabled rather than hidden for a user whose
* attributes fail the policy, so the restriction is visible instead of surfacing as a
* server error after the fact.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'MM-T5822 disables the upload control for a user whose attributes fail the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department does not match the policy
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the attachment control stays visible but is not actionable
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeVisible({
timeout: pw.duration.half_min,
});
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled();
},
);
/**
* @objective Verify a single denied user is restricted on both file actions at once — files
* redacted and upload blocked in the same view.
*
* @precondition
* A licensed server with a permission policy granting both file permissions to Department == "Engineering"
*/
test(
'MM-T5824 redacts files and blocks upload together for a denied user',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department does not match the policy
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify existing files are redacted
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesRedacted();
// * Verify new uploads are not offered
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
},
);
/**
* @objective Verify a rendered "allowed" is backed by live enforcement — the user the UI
* offers upload to can actually complete one, so the affordance and the server agree.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'accepts a real upload from a user the render decision allows',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # Log in as the user whose Department matches the policy
const {channelsPage} = await pw.testBrowser.login(allowedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
// # Upload a file
const message = `Upload from allowed user ${getRandomId()}`;
await channelsPage.centerView.postCreate.postMessage(message, ['sample_text_file.txt']);
// * Verify the post was accepted with its attachment intact
const post = await channelsPage.centerView.getPostByText(message);
await post.toContainText(message);
await post.toHaveFilesVisible();
},
);
});
/**
* With ABAC enabled but no permission policy in existence, every file action is implicitly
* allowed. Separate fixture from the describe above because it needs the policy table empty.
*/
test.describe('ABAC file permissions - implicit allow with no policy', () => {
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
});
/**
* @objective Verify existing file attachments render normally when no permission policy
* restricts the user.
*/
test(
'MM-T5821 shows file attachments when no permission policy exists',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # Set up a team, a private channel, and a member of it
const {adminUser, adminClient, team} = await pw.initSetup();
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
await cleanupAllPermissionPolicies(adminClient);
// # Admin posts a file into the channel
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(NO_POLICY_MESSAGE, ['sample_text_file.txt']);
// # Enable ABAC without creating any policy
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await ensureABACEnabled(adminClient);
// # The member opens the channel
const {channelsPage} = await pw.testBrowser.login(testUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the file renders rather than being redacted
const post = await channelsPage.centerView.getPostByText(NO_POLICY_MESSAGE);
await post.toHaveFilesVisible();
},
);
/**
* @objective Verify a user can attach and send a file when no permission policy restricts
* uploads.
*/
test(
'MM-T5823 accepts a file upload when no permission policy exists',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # Set up a team, a private channel, and a member of it
const {adminUser, adminClient, team} = await pw.initSetup();
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
await cleanupAllPermissionPolicies(adminClient);
// # Enable ABAC without creating any policy
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await ensureABACEnabled(adminClient);
// # The member uploads a file
const {channelsPage} = await pw.testBrowser.login(testUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
const message = `Upload test ${getRandomId()}`;
await channelsPage.centerView.postCreate.postMessage(message, ['sample_text_file.txt']);
// * Verify the post was accepted with its attachment intact
const post = await channelsPage.centerView.getPostByText(message);
await post.toContainText(message);
await post.toHaveFilesVisible();
},
);
});

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, getRandomId, newTestPassword} from '@mattermost/playwright-lib';
import {createPrivateChannelForABAC, ensureUserAttributes, getPolicyIdByName} from '../support';
export async function setupUserAndChannel(
adminClient: any,
team: any,
): Promise<{
testUser: any;
channelName: string;
channelId: string;
}> {
// Ensure at least one user attribute field exists so the permission policy
// CEL editor's "Switch to Advanced Mode" button is enabled in the UI.
await ensureUserAttributes(adminClient, ['Department']);
const username = `user${getRandomId()}`;
const password = newTestPassword();
const testUser = await adminClient.createUser(
{email: `${username}@example.com`, username, password} as any,
'',
'',
);
(testUser as any).password = password;
await adminClient.addToTeam(team.id, testUser.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(testUser.id, channel.id);
return {testUser, channelName: channel.name, channelId: channel.id};
}
/**
* Every initSetup() rewrites the shared server config, so an earlier spec can leave ABAC off.
* Without this the policy is created but never enforced, and the allow-side tests pass blindly.
*/
export async function ensureABACEnabled(adminClient: Client4): Promise<void> {
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as any);
await expect
.poll(
async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
},
{timeout: 15000, intervals: [500, 1000, 2000]},
)
.toBe(true);
}
/** Block until a just-created policy is readable through the search API. */
export async function waitForPolicy(adminClient: Client4, policyName: string): Promise<void> {
await expect
.poll(async () => Boolean(await getPolicyIdByName(adminClient, policyName, 1)), {
timeout: 30000,
intervals: [500, 1000, 2000],
})
.toBe(true);
}