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,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {ensureUserAttributes} from '../support';
/**
* Check whether the PermissionPolicies feature flag is enabled at runtime.
* Returns true when the server exposes the permission_policies route.
*/
async function isPermissionPoliciesEnabled(adminClient: any): Promise<boolean> {
const config = await adminClient.getConfig();
return config.FeatureFlags?.PermissionPolicies === true || config.FeatureFlags?.PermissionPolicies === 'true';
}
/**
* ABAC Basic Operations - Enable/Disable
* Tests basic ABAC system-wide enable/disable functionality
*/
test('MM-T5782 System admin can enable or disable system-wide ABAC', async ({pw}) => {
test.setTimeout(120000);
// # Skip test if no license for ABAC
await pw.skipIfNoLicense();
// # Set up admin user and login
const {adminUser, adminClient} = await pw.initSetup();
// # Ensure user attributes exist BEFORE logging in
await ensureUserAttributes(adminClient);
// # Reset ABAC to disabled via API before testing the UI toggle.
// Parallel tests may have already enabled it, which would leave the radio
// pre-selected and the Save button permanently disabled (no dirty state).
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: false,
},
} as any);
// # Now login - this ensures the UI will have the attributes loaded
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Navigate to ABAC page
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.systemAttributes.attributeBasedAccess.click();
// Re-apply the ABAC=false reset right before UI interaction: a concurrent
// initSetup() on another shard may have re-enabled ABAC between the initial
// patchConfig call above and here. If it's already enabled when we click
// enableRadio the radio is a no-op and Save stays disabled.
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: false,
},
} as any);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === false;
});
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify we're on the correct page
const abacSection = systemConsolePage.page.getByTestId('sysconsole_section_AttributeBasedAccessControl');
await expect(abacSection).toBeVisible();
const enableRadio = systemConsolePage.page.locator(
'#AccessControlSettings\\.EnableAttributeBasedAccessControltrue',
);
const disableRadio = systemConsolePage.page.locator(
'#AccessControlSettings\\.EnableAttributeBasedAccessControlfalse',
);
const saveButton = systemConsolePage.page.getByRole('button', {name: 'Save'});
// # Test enable ABAC
await enableRadio.click();
await expect(enableRadio).toBeChecked();
await saveButton.click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the Attribute-Based Access page only has the toggle — no policy management here
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
// * Verify Membership Policies page shows "Add policy" when ABAC is enabled
// Re-apply enable guard: a concurrent shard may have disabled ABAC between the
// save above and this navigation, which would cause a redirect to the license page.
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
});
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
// * Verify Permission Policies page shows "Add policy" when ABAC is enabled
// This section is only testable when the PermissionPolicies feature flag is on.
if (await isPermissionPoliciesEnabled(adminClient)) {
await systemConsolePage.page.goto('/admin_console/system_attributes/permission_policies');
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
}
// # Navigate back to Attribute-Based Access to test disable
await systemConsolePage.page.goto('/admin_console/system_attributes/attribute_based_access_control');
await systemConsolePage.page.waitForLoadState('networkidle');
// # Test disable ABAC
await disableRadio.click();
await expect(disableRadio).toBeChecked();
await saveButton.click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify Membership Policies no longer shows "Add policy" when ABAC is disabled
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
// # Re-enable ABAC for subsequent tests via API — avoids the race where a concurrent
// shard's initSetup() re-enables ABAC between the disable save and here, leaving the
// radio already checked so the UI save button stays disabled.
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
});

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);
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5797a: LDAP sync - User auto-added with `= is` operator (auto-add true)
*
* 1. Policy with Department == Engineering, auto-add=true
* 2. User has non-qualifying attribute (Sales) → not added on first sync
* 3. Attribute updated to Engineering (simulating LDAP sync)
* 4. Next sync auto-adds the user
*/
test('MM-T5797a LDAP sync - User auto-added with == operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient, ['Department']);
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `LDAP AutoAdd Equals ${await pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await activatePolicy(adminClient, policyId);
// Initial sync — user has non-qualifying attribute, should not be added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5797a1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797a1);
// Poll: sync job marks itself success before channel_members write is committed.
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should NOT be in channel after first sync (Department=Sales)',
})
.toBe(false);
// Simulate LDAP sync: update attribute to qualifying value.
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
// Sync again — user now qualifies and should be auto-added.
const syncJob5797a2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797a2);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should be in channel after second sync (Department=Engineering)',
})
.toBe(true);
});
/**
* MM-T5797b: LDAP sync - User auto-added with `contains` operator (auto-add true)
*
* 1. Policy with Department.contains("Eng"), auto-add=true
* 2. User has non-qualifying attribute (Sales) → not added on first sync
* 3. Attribute updated to Engineering (simulating LDAP sync)
* 4. Next sync auto-adds the user
*/
test('MM-T5797b LDAP sync - User auto-added with contains operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient, ['Department']);
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `LDAP AutoAdd Contains ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department.contains("Eng")',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await activatePolicy(adminClient, policyId);
// Initial sync — user has non-qualifying attribute, should not be added.
const syncJob5797b1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797b1);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should NOT be in channel after first sync (Department=Sales)',
})
.toBe(false);
// Simulate LDAP sync: update Department to value containing "Eng".
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
// Sync again — user now qualifies and should be auto-added.
const syncJob5797b2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797b2);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should be in channel after second sync (Department=Engineering)',
})
.toBe(true);
});

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* ABAC LDAP Integration - Sync
* Tests for LDAP sync behavior with ABAC policies
*/
test.describe('ABAC LDAP Integration - Sync', () => {
/**
* MM-T5798: LDAP sync - User can be added to channel by admin after editing qualifying attribute (auto-add false)
*
* Step 1: Using `= is` operator
* 1. Policy with auto-add=false exists and is applied to a channel
* 2. User has wrong attribute value (non-qualifying)
* 3. Simulate LDAP sync by updating user's attribute to qualifying value
* 4. Run ABAC sync job (updates qualification state but doesn't auto-add due to auto-add=false)
* 5. Verify user NOT auto-added
* 6. Admin manually adds user to channel
*
* Step 2: Using `∈ in` operator
* 1. Policy with `in` operator exists
* 2. User has attribute but not a qualifying value
* 3. Simulate LDAP sync by updating to qualifying value
* 4. Admin adds user to channel
*
* Expected:
* - User who satisfies policy can be added by admin
* - `User added` message posted in channel
*
* NOTE: This test simulates LDAP attribute sync behavior via API.
* In production, attributes would be synced from LDAP server.
*/
test('MM-T5798 User added by admin after LDAP attribute sync (auto-add false)', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
// ============================================================
// STEP 1: Test with `= is` operator
// ============================================================
// Create user with NON-qualifying attribute (simulating LDAP user before sync)
const user1 = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user1.id);
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy1Name = `LDAP Sync Equals ${pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policy1Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [channel1.display_name],
});
// Get policy ID via API (no DOM scraping, no page reload needed)
const policyId1 = (await getPolicyIdByName(adminClient, policy1Name))!;
await activatePolicy(adminClient, policyId1);
// Initial sync — user has non-qualifying attribute, should not be added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5798a1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798a1);
const user1InitialCheck = await verifyUserInChannel(adminClient, user1.id, channel1.id);
expect(user1InitialCheck).toBe(false);
// Simulate LDAP sync: update attribute to qualifying value
await updateUserAttributes(adminClient, user1.id, {Department: 'Engineering'});
// Sync again — auto-add=false, so user is NOT auto-added even when qualifying
const syncJob5798a2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798a2);
const user1AfterSync = await verifyUserInChannel(adminClient, user1.id, channel1.id);
if (!user1AfterSync) {
// Expected: admin must manually add qualifying user when auto-add=false
await adminClient.addToChannel(user1.id, channel1.id);
const user1AfterAdminAdd = await verifyUserInChannel(adminClient, user1.id, channel1.id);
expect(user1AfterAdminAdd).toBe(true);
}
const user1Final = await verifyUserInChannel(adminClient, user1.id, channel1.id);
expect(user1Final).toBe(true);
// ============================================================
// STEP 2: Test with `∈ in` operator
// ============================================================
const user2 = await createUserWithAttributes(adminClient, {Department: 'Marketing'});
await adminClient.addToTeam(team.id, user2.id);
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
await navigateToABACPage(systemConsolePage.page);
const policy2Name = `LDAP Sync In ${pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policy2Name,
celExpression: 'user.attributes.Department in ["Engineering", "Product"]',
autoSync: false,
channels: [channel2.display_name],
});
const policyId2 = (await getPolicyIdByName(adminClient, policy2Name))!;
await activatePolicy(adminClient, policyId2);
// Initial sync — non-qualifying, should not be added
const syncJob5798b1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798b1);
const user2InitialCheck = await verifyUserInChannel(adminClient, user2.id, channel2.id);
expect(user2InitialCheck).toBe(false);
// Simulate LDAP sync: update to qualifying value
await updateUserAttributes(adminClient, user2.id, {Department: 'Product'});
// Sync again — auto-add=false, admin must manually add
const syncJob5798b2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798b2);
const user2AfterSync = await verifyUserInChannel(adminClient, user2.id, channel2.id);
if (!user2AfterSync) {
await adminClient.addToChannel(user2.id, channel2.id);
const user2AfterAdminAdd = await verifyUserInChannel(adminClient, user2.id, channel2.id);
expect(user2AfterAdminAdd).toBe(true);
}
const user2Final = await verifyUserInChannel(adminClient, user2.id, channel2.id);
expect(user2Final).toBe(true);
});
});

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
} from '../support';
/**
* ABAC LDAP Integration - Sync
* Tests for LDAP sync behavior with ABAC policies
*/
test.describe('ABAC LDAP Integration - Sync', () => {
/**
* MM-T5800: Policy enforcement after attribute change
* @objective Verify that policy enforcement updates when user attributes change
*
* This test is similar to MM-T5794 but focuses on the bidirectional nature:
* - User starts with non-qualifying attribute → NOT in channel
* - Attribute changed to qualifying value → User auto-added
* - Attribute changed back to non-qualifying → User auto-removed
*/
test('MM-T5800 Policy enforcement after attribute change (bidirectional)', async ({pw}) => {
// 4 x waitForLatestSyncJob at up to 180 s each, plus policy creation and browser
// navigation — CI LDAP sync jobs can take significantly longer than the default 90 s.
test.setTimeout(300000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
// Create user with Sales department (non-qualifying)
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// Create policy for Engineering with auto-add
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `Dynamic Policy ${pw.random.id()}`;
const createJobId = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [privateChannel.display_name],
});
// Activate policy
await waitForLatestSyncJob(systemConsolePage.page, undefined, createJobId, 180_000);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(adminClient, policyId);
}
await searchInput.clear();
// ============================================================
// PHASE 1: User should NOT be added (Department=Sales)
// ============================================================
const syncJob1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob1, 180_000);
const phase1InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase1InChannel).toBe(false);
// ============================================================
// PHASE 2: Change attribute to qualifying value → User auto-added
// ============================================================
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2, 180_000);
const phase2InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase2InChannel).toBe(true);
// ============================================================
// PHASE 3: Change attribute back → User auto-removed
// ============================================================
await updateUserAttributes(adminClient, user.id, {Department: 'Marketing'});
const syncJob3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob3, 180_000);
const phase3InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase3InChannel).toBe(false);
});
});

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5800: Policy enforcement after attribute change (bidirectional)
*/
test('MM-T5800 Policy enforcement after attribute change (bidirectional)', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure the "Department" custom profile attribute exists before creating users.
// A concurrent test shard may not yet have run the global setup, or the attribute
// may have been recreated under a different ID — ensureUserAttributes is idempotent.
await ensureUserAttributes(adminClient);
// User starts with non-qualifying attribute.
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const policyName = `Dynamic Policy ${await pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [privateChannel.display_name],
});
const t5800PolicyId = (await getPolicyIdByName(adminClient, policyName))!;
await activatePolicy(adminClient, t5800PolicyId);
// PHASE 1: User has non-qualifying attribute — not in channel without a sync.
const phase1InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase1InChannel).toBe(false);
// PHASE 2: Change to qualifying → User auto-added.
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
// Capture exact job ID so waitForLatestSyncJob polls the right job, not
// the most-recent row (which may belong to a concurrent shard's sync job).
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, privateChannel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been added to channel after qualifying sync',
})
.toBe(true);
// PHASE 3: Change back to non-qualifying → User auto-removed.
await updateUserAttributes(adminClient, user.id, {Department: 'Marketing'});
const syncJob3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob3);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, privateChannel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been removed from channel after non-qualifying sync',
})
.toBe(false);
});

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5799b: LDAP sync - User removed after attribute removed (== operator, auto-add true)
*/
test('MM-T5799b LDAP sync - User removed with == operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient, ['Department']);
// User starts WITH qualifying attribute.
const user2 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
await adminClient.addToTeam(team.id, user2.id);
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy2Name = `LDAP Remove TwoAttr ${await pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policy2Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [channel2.display_name],
});
const t5799Policy2Id = (await getPolicyIdByName(adminClient, policy2Name))!;
await activatePolicy(adminClient, t5799Policy2Id);
// Sync: user has qualifying attribute → gets auto-added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5799b1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799b1);
await expect
.poll(() => verifyUserInChannel(adminClient, user2.id, channel2.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been added to channel after first sync',
})
.toBe(true);
// Change Department to non-qualifying value.
await updateUserAttributes(adminClient, user2.id, {Department: 'Sales'});
// Sync: user no longer qualifies → gets removed.
const syncJob5799b2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799b2);
await expect
.poll(() => verifyUserInChannel(adminClient, user2.id, channel2.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been removed from channel after second sync',
})
.toBe(false);
});

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5799a: LDAP sync - User removed after attribute removed (startsWith operator, auto-add true)
*/
test('MM-T5799a LDAP sync - User removed with startsWith operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure the "Department" attribute exists — may not be present if global
// setup hasn't run yet or ran on a different shard.
await ensureUserAttributes(adminClient, ['Department']);
// User starts WITH qualifying attribute (Department starts with "Eng").
const user1 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
await adminClient.addToTeam(team.id, user1.id);
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy1Name = `LDAP Remove StartsWith ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policy1Name,
celExpression: 'user.attributes.Department.startsWith("Eng")',
autoSync: true,
channels: [channel1.display_name],
});
const t5799Policy1Id = (await getPolicyIdByName(adminClient, policy1Name))!;
// Activate immediately using the UUID — no creation-sync wait needed.
await activatePolicy(adminClient, t5799Policy1Id);
// Sync: user has qualifying attribute → gets auto-added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5799a1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799a1);
// Poll: the sync job marks itself success before the channel_members write
// is fully committed. Give the server up to 15 s to catch up.
await expect
.poll(() => verifyUserInChannel(adminClient, user1.id, channel1.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been added to channel after first sync',
})
.toBe(true);
// Simulate LDAP sync: change Department to non-qualifying value.
await updateUserAttributes(adminClient, user1.id, {Department: 'Sales'});
// Sync: user no longer qualifies → gets removed.
const syncJob5799a2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799a2);
await expect
.poll(() => verifyUserInChannel(adminClient, user1.id, channel1.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been removed from channel after second sync',
})
.toBe(false);
});

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ChannelsPage, expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {
assignChannelsToPolicy,
createPrivateChannel,
createTeamAdmin,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {assignTeamsToPolicy} from '../teams/helpers';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
setUserAttribute,
} from './masking_helpers';
import {deleteFieldFromDB, purgeFieldsByPrefix, setFieldAsSharedOnly, setFieldAsSourceOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingAR';
test.describe('Attribute-Value Masking - Admin Roles', {tag: ['@abac', '@abac_masking']}, () => {
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-18: Team admin cannot delete a policy with masked values even after removing all channels', async ({
pw,
}) => {
// Validates that the masked-values block applies to the team settings modal:
// the Delete button stays disabled even after a team admin removes all assigned
// channels from the policy, as long as masked values are present.
// The server also returns HTTP 403 for a direct DELETE request.
test.setTimeout(150000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha"; policy has ["Alpha", "Bravo"] — Bravo is masked
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const sysPage = systemConsolePage.page;
await navigateToABACPage(sysPage);
await enableABAC(sysPage);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
sysPage,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo"]`,
);
policyIds.push(policyId);
// Assign channel first so the attribute view can be primed before
// we flip the field to shared_only (self-inclusion filter in
// SearchTeamAccessPolicies requires the view to be current).
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createPrivateChannel(adminClient, team.id);
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await waitForAttributeViewToInclude(adminClient, `user.attributes.${fieldName} in ["Alpha", "Bravo"]`, [
adminUser.id,
]);
await setFieldAsSharedOnly(fieldId);
await assignTeamsToPolicy(adminClient, policyId, [team.id]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const teamSettings = await channelsPage.openTeamSettings();
await teamSettings.openAccessPoliciesTab();
const policyRow = teamSettings.container.getByText(policyName).first();
await expect(policyRow).toBeVisible({timeout: 10000});
await policyRow.click();
await page.waitForTimeout(500);
const deleteBtn = teamSettings.container
.locator('.TeamPolicyEditor__section--delete button')
.filter({hasText: 'Delete'});
await expect(deleteBtn).toBeVisible({timeout: 3000});
await expect(deleteBtn).toBeDisabled();
// Remove the channel — button must STAY disabled due to masked values
const removeLink = teamSettings.container.getByText('Remove').first();
await expect(removeLink).toBeVisible({timeout: 5000});
await removeLink.click();
await page.waitForTimeout(300);
await expect(deleteBtn).toBeDisabled();
await teamSettings.close();
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
// Server: direct DELETE must return HTTP 403 regardless of UI state
const status = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
}, policyId);
expect(status, `DELETE /api/v4/access_control_policies/${policyId} returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-20: Team admin (non-sysadmin) sees the same masking as a system admin in team settings', async ({
pw,
}) => {
// Role-neutrality across roles: a delegated team admin (granted
// PermissionManageTeamAccessRules by their team_admin role, but NOT
// PermissionManageSystem) must see masking in the team-settings access
// policy editor. The masked-values guard MUST apply at this surface too:
// controls locked, Delete disabled, server 403 on direct DELETE.
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const teamAdmin = await createTeamAdmin(adminClient, team.id);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, teamAdmin.id, fieldId, 'Alpha');
const channel = await createPrivateChannel(adminClient, team.id);
await adminClient.addToChannel(teamAdmin.id, channel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const sysPage = systemConsolePage.page;
await navigateToABACPage(sysPage);
await enableABAC(sysPage);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyExpression = `user.attributes.${fieldName} in ["Alpha", "Bravo"]`;
const policyResp = await (adminClient as any).doFetch(
`${(adminClient as any).getBaseRoute()}/access_control_policies`,
{
method: 'PUT',
body: JSON.stringify({
name: policyName,
type: 'parent',
version: 'v0.3',
revision: 1,
rules: [
{
actions: ['membership'],
expression: policyExpression,
},
],
}),
},
);
const policyId = policyResp.id as string;
policyIds.push(policyId);
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await waitForAttributeViewToInclude(adminClient, policyExpression, [teamAdmin.id]);
await setFieldAsSharedOnly(fieldId);
// Log in AS THE TEAM ADMIN (not the sysadmin)
const {page} = await pw.testBrowser.login(teamAdmin);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const teamSettings = await channelsPage.openTeamSettings();
await teamSettings.openAccessPoliciesTab();
const searchInput = teamSettings.container.locator('[data-testid="searchInput"]').first();
await expect(searchInput).toBeVisible({timeout: 10000});
const searchResponse = page.waitForResponse(
(resp) =>
/\/api\/v4\/access_control_policies\/search$/.test(resp.url()) &&
resp.request().method() === 'POST' &&
Boolean(resp.request().postData()?.includes(policyName)) &&
resp.ok(),
{timeout: 15000},
);
await searchInput.fill(policyName);
await searchResponse.catch(() => {
// debounced search can occasionally settle from cached data
});
await page.waitForLoadState('networkidle');
const policyRow = teamSettings.container.getByText(policyName).first();
await expect(policyRow).toBeVisible({timeout: 10000});
await policyRow.click();
await page.waitForTimeout(500);
// Masking surfaces in the team-policy editor exactly as in the system console
await expect(teamSettings.container.locator('.select__multi-value--masked').first()).toBeVisible({
timeout: 5000,
});
const deleteBtn = teamSettings.container
.locator('.TeamPolicyEditor__section--delete button')
.filter({hasText: 'Delete'});
await expect(deleteBtn).toBeVisible({timeout: 5000});
await expect(deleteBtn).toBeDisabled();
await teamSettings.close();
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
const status = await page.evaluate(
async ({id, teamId}: {id: string; teamId: string}) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}?team_id=${teamId}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
},
{id: policyId, teamId: team.id},
);
expect(status, `DELETE as team admin returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-21: Channel admin (non-sysadmin) sees the same masking as a system admin in channel settings', async ({
pw,
}) => {
// Role-neutrality for the channel-admin surface: a user with
// PermissionManageChannelAccessRules (via channel_admin role) on a
// private channel must see masking inside the Membership Policy tab of
// the channel settings modal.
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `mp-${pw.random.id()}`.toLowerCase(),
display_name: `Masked Policy Channel ${pw.random.id()}`,
type: 'P',
purpose: '',
header: '',
} as any);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.updateChannelMemberRoles(channel.id, user.id, 'channel_user channel_admin');
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, user.id, fieldId, 'Alpha');
// Sysadmin authors a CHANNEL-level policy directly
const channelPolicyResp = await (adminClient as any).doFetch(
`${(adminClient as any).getBaseRoute()}/access_control_policies`,
{
method: 'PUT',
body: JSON.stringify({
id: channel.id,
type: 'channel',
version: 'v0.3',
revision: 1,
rules: [
{actions: ['membership'], expression: `user.attributes.${fieldName} in ["Alpha", "Bravo"]`},
],
}),
},
);
const policyId = (channelPolicyResp?.id ?? channel.id) as string;
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
// Log in AS THE CHANNEL ADMIN (not the sysadmin)
const {page} = await pw.testBrowser.login(user);
const channelsPage = new ChannelsPage(page);
await page.goto(`/${team.name}/channels/${channel.name}`);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
const membershipPolicyTab = channelSettings.container.getByRole('tab', {name: /membership policy/i});
await membershipPolicyTab.waitFor({state: 'visible', timeout: 10000});
await membershipPolicyTab.click();
await page.waitForTimeout(1500);
await expect(channelSettings.container.locator('.select__multi-value--masked').first()).toBeVisible({
timeout: 10000,
});
// Server-side guard: direct DELETE by the channel admin must 403
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
const status = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
}, policyId);
expect(status, `DELETE as channel admin returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-23: source_only and shared_only fields are filtered from the channel members RHS attribute tags', async ({
pw,
}) => {
// Validates that the /attributes endpoint strips source_only and shared_only
// fields before they reach the channel members RHS panel. A public field in
// the same policy must still appear so we confirm the filter is selective.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const id = pw.random.id();
const publicFieldName = `${fieldPrefix}Pub_${id}`;
const sharedFieldName = `${fieldPrefix}Sh_${id}`;
const sourceFieldName = `${fieldPrefix}Src_${id}`;
// Create all three fields as public first — the API rejects protected
// access modes without a source_plugin_id, so we flip them via DB after creation.
const publicFieldId = await createMaskingTextField(adminClient, publicFieldName);
const sharedFieldId = await createMaskingTextField(adminClient, sharedFieldName);
const sourceFieldId = await createMaskingTextField(adminClient, sourceFieldName);
fieldIds.push(publicFieldId, sharedFieldId, sourceFieldId);
await setUserAttribute(adminClient, adminUser.id, publicFieldId, 'Alpha');
await setUserAttribute(adminClient, adminUser.id, sharedFieldId, 'Beta');
await setUserAttribute(adminClient, adminUser.id, sourceFieldId, 'Gamma');
const {channelsPage, page} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${publicFieldName} in ["Alpha"] && user.attributes.${sharedFieldName} in ["Beta"] && user.attributes.${sourceFieldName} in ["Gamma"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(sharedFieldId);
await setFieldAsSourceOnly(sourceFieldId);
const channel = await createPrivateChannel(adminClient, team.id);
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// The enforcement cache is cold on the first request — retry until the
// public-field tag is visible (up to 6 attempts with reload).
const alertContainer = page.locator('.channel-members-rhs__alert-container.policy-enforced');
let publicTagVisible = false;
for (let attempt = 0; attempt < 6; attempt++) {
if (attempt > 0) {
await page.keyboard.press('Escape');
await page.waitForTimeout(3000);
await page.reload();
await channelsPage.toBeVisible();
}
await channelsPage.centerView.header.openChannelMenu();
await page.locator('#channelMembers').click();
await channelsPage.sidebarRight.toBeVisible();
try {
await alertContainer.waitFor({state: 'visible', timeout: 10000});
publicTagVisible = await alertContainer.getByText(/:\s*Alpha/).isVisible();
if (publicTagVisible) {
break;
}
} catch {
// alert container not yet visible, retry
}
}
// Public field (value "Alpha") MUST be visible
await expect(alertContainer.getByText(/:\s*Alpha/)).toBeVisible({timeout: 5000});
// shared_only (value "Beta") and source_only (value "Gamma") must NOT appear
await expect(alertContainer.getByText(/:\s*Beta/)).not.toBeVisible();
await expect(alertContainer.getByText(/:\s*Gamma/)).not.toBeVisible();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,654 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
getPolicyIdFromURL,
getRawPolicyExpression,
openExistingPolicy,
resubmitPolicyExpression,
searchPoliciesExpression,
setUserAttribute,
} from './masking_helpers';
import {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingAD';
test.describe('Attribute-Value Masking - API Redaction and Delete', {tag: ['@abac', '@abac_masking']}, () => {
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-12: GET /policies/{id} does not leak raw CEL when values are masked', async ({pw}) => {
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
const storedPolicyId = await getPolicyIdFromURL(page);
expect(storedPolicyId).toBeTruthy();
// GET policy as the logged-in user (holds "Alpha" only). Hidden values
// must be replaced with the masked-token sentinel.
const expression = await getRawPolicyExpression(page, storedPolicyId);
expect(expression).toContain('Alpha');
expect(expression).toContain('--------');
expect(expression).not.toContain('Bravo');
expect(expression).not.toContain('Charlie');
// Direct DB read bypasses the API masking pipeline — stored expression
// must still contain the originals.
const rawExpression = (await getStoredPolicyRuleExpressions(storedPolicyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-13: POST /policies/search does not leak raw CEL when values are masked', async ({pw}) => {
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
// Search as the logged-in (masked) user — the response must contain the
// masked-token sentinel for any hidden values, never the raw originals.
const maskedExpression = await searchPoliciesExpression(page, policyName);
expect(maskedExpression).toContain('--------');
expect(maskedExpression).not.toContain('Bravo');
expect(maskedExpression).not.toContain('Charlie');
// Verify the stored policy still contains the originals — direct DB read.
const rawExpression = (await getStoredPolicyRuleExpressions(policyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-14: Warning banner visible in editor when policy has masked values', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy with masked values (admin holds Alpha; Bravo/Charlie are masked)
const maskedPolicyName = `MaskingPolicy ${pw.random.id()}`;
const maskedPolicyId = await createPolicyWithCEL(
page,
maskedPolicyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(maskedPolicyId);
// Policy with NO masked values (admin holds the only value in the condition)
const cleanPolicyName = `CleanPolicy ${pw.random.id()}`;
const cleanPolicyId = await createPolicyWithCEL(
page,
cleanPolicyName,
`user.attributes.${fieldName} in ["Alpha"]`,
);
policyIds.push(cleanPolicyId);
// shared_only must flip AFTER both policy saves
await setFieldAsSharedOnly(fieldId);
// Open masked policy — warning banner must be present
await openExistingPolicy(page, maskedPolicyName);
await expect(page.locator('text="This policy contains restricted values"')).toBeVisible({timeout: 5000});
// Open clean policy — warning banner must NOT be present
await openExistingPolicy(page, cleanPolicyName);
await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-15: Delete button is disabled on masked policies; clean policies open the standard confirmation modal', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy WITH masked values
const maskedPolicyName = `MaskingPolicy ${pw.random.id()}`;
const maskedPolicyId = await createPolicyWithCEL(
page,
maskedPolicyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(maskedPolicyId);
// Policy WITHOUT masked values
const cleanPolicyName = `CleanPolicy ${pw.random.id()}`;
const cleanPolicyId = await createPolicyWithCEL(
page,
cleanPolicyName,
`user.attributes.${fieldName} in ["Alpha"]`,
);
policyIds.push(cleanPolicyId);
await setFieldAsSharedOnly(fieldId);
// --- Masked policy: Delete button must be disabled (no modal flow) ---
await openExistingPolicy(page, maskedPolicyName);
const deleteBtn = page.getByRole('button', {name: /delete policy|delete/i}).last();
await deleteBtn.scrollIntoViewIfNeeded();
await expect(deleteBtn).toBeDisabled();
// --- Clean policy: Delete button must be enabled and open a normal
// confirmation modal without the "restricted values" warning ---
await openExistingPolicy(page, cleanPolicyName);
const cleanDeleteBtn = page.getByRole('button', {name: /delete policy|delete/i}).last();
await cleanDeleteBtn.scrollIntoViewIfNeeded();
await expect(cleanDeleteBtn).toBeEnabled();
await cleanDeleteBtn.click();
await page.waitForTimeout(500);
const cleanModal = page.locator('[role="dialog"]').filter({hasText: /confirm|delete/i});
await cleanModal.waitFor({state: 'visible', timeout: 5000});
await expect(cleanModal.locator('text=/restricted values/i')).not.toBeVisible();
await cleanModal.getByRole('button', {name: /cancel/i}).click();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-16: Delete Policy is blocked (UI and server) when caller has masked values', async ({pw}) => {
// Validates that the read-only-when-masked invariant covers deletion:
// - Delete Policy button in the UI is disabled when hasMaskedRows is true
// - Server returns HTTP 403 for direct DELETE requests when caller has masked values
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// UI: Delete Policy button must be disabled when masked values present
const deleteBtn = page.getByRole('button', {name: /^delete$/i}).last();
await expect(deleteBtn).toBeVisible({timeout: 5000});
await expect(deleteBtn).toBeDisabled();
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
// Server: direct DELETE must return HTTP 403
const status = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
}, policyId);
expect(status, `DELETE /api/v4/access_control_policies/${policyId} returned ${status}`).toBe(403);
// Verify policy still exists via DB
const expression = (await getStoredPolicyRuleExpressions(policyId))[0] ?? '';
expect(expression).toContain('Alpha');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-17: Multi-condition save preserves all hidden values; deleting masked row is blocked', async ({
pw,
}) => {
// Validates merge-on-save for a multi-condition policy. The caller (holds Alpha
// in programField, nothing in clearanceField) can save — both conditions survive
// with their hidden values intact. The server blocks deletion of masked conditions.
test.setTimeout(150000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const programFieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const clearanceFieldName = `${fieldPrefix}Clear_${pw.random.id()}`;
const programFieldId = await createMaskingTextField(adminClient, programFieldName);
const clearanceFieldId = await createMaskingTextField(adminClient, clearanceFieldName);
fieldIds.push(programFieldId, clearanceFieldId);
await setUserAttribute(adminClient, adminUser.id, programFieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingRegressionPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${programFieldName} in ["Alpha", "Bravo", "Charlie"] && user.attributes.${clearanceFieldName} in ["Secret", "TopSecret"]`,
);
policyIds.push(policyId);
// shared_only must flip AFTER the policy save for both fields
await setFieldAsSharedOnly(programFieldId);
await setFieldAsSharedOnly(clearanceFieldId);
await openExistingPolicy(page, policyName);
const storedPolicyId = await getPolicyIdFromURL(page);
// Both rows are masked — banner visible
await expect(page.locator('.select__multi-value--masked').first()).toBeVisible();
await expect(page.locator('text="This policy contains restricted values"')).toBeVisible();
const saveBtn = page.getByRole('button', {name: 'Save'});
// Trash buttons on both masked rows must be DISABLED
const trashButtons = page.locator('button[aria-label="Remove row"]');
const firstTrash = trashButtons.first();
if (await firstTrash.isVisible({timeout: 3000})) {
await expect(firstTrash).toBeDisabled();
}
// Dirty the form via the policy name so Save enables
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.fill(policyName + ' (edited)');
await page.waitForTimeout(300);
await saveBtn.click();
await page.waitForLoadState('networkidle');
// Verify the stored policy directly — all hidden values must survive merge-on-save
const rawExpression = (await getStoredPolicyRuleExpressions(storedPolicyId))[0] ?? '';
expect(rawExpression).toContain(programFieldId);
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
expect(rawExpression).toContain(clearanceFieldId);
expect(rawExpression).toContain('Secret');
expect(rawExpression).toContain('TopSecret');
// Server blocks a direct API attempt to remove a masked condition
const status = await page.evaluate(
async ({policyId: id, fn}: {policyId: string; fn: string}) => {
const resp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
body: JSON.stringify({
id,
name: 'Modified',
type: 'parent',
rules: [{expression: `user.attributes.${fn} in ["Alpha"]`}],
}),
});
return resp.status;
},
{policyId, fn: programFieldName},
);
expect(status, `PUT /api/v4/access_control_policies (id=${policyId}) returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-19: Mode toggle Simple → Advanced → Simple preserves all masked-row restrictions', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// --- Initial Simple mode: restrictions in place ---
const maskedChip = page.locator('.select__multi-value--masked');
const banner = page.locator('text="This policy contains restricted values"');
const deleteBtn = page.getByRole('button', {name: /^delete$/i}).last();
await expect(maskedChip.first()).toBeVisible();
await expect(banner).toBeVisible();
await expect(deleteBtn).toBeDisabled();
// --- Switch to Advanced mode ---
const toAdvanced = page.getByRole('button', {name: /switch to advanced mode/i});
await toAdvanced.click();
await page.waitForTimeout(500);
// Banner must persist across the toggle
await expect(banner).toBeVisible();
await expect(page.locator('.monaco-editor').first()).toBeVisible();
// --- Switch back to Simple mode ---
const toSimple = page.getByRole('button', {name: /switch to simple mode/i});
await toSimple.click();
await page.waitForTimeout(1500);
// All restrictions must STILL be in place after toggling back
await expect(banner).toBeVisible();
await expect(maskedChip.first()).toBeVisible();
await expect(deleteBtn).toBeDisabled();
const valueSelector = page.locator('[data-testid="valueSelectorMenuButton"]').first();
if (await valueSelector.isVisible({timeout: 2000})) {
await expect(valueSelector).toBeDisabled();
}
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-22: PUT re-submitting "false" over a rule with hidden values is rejected (403), stored policy untouched', async ({
pw,
}) => {
// The genuinely dangerous scenario: a caller re-submits the deny-all
// sentinel "false" over a stored rule whose values they cannot see, which
// would silently wipe a masked rule. The canonical merge can't pair the
// bare "false" with the stored masked node, so it must fail closed (403)
// and leave the stored expression untouched. The merge is the actual line
// of defence — rejectMaskedTokens' former "== false" check was only a
// redundant (and overbroad) extra layer on top of it. This is the
// end-to-end proof of that protection.
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
// Flip to shared_only AFTER the save: the admin now holds only "Alpha",
// so "Bravo"/"Charlie" become hidden from this caller.
await setFieldAsSharedOnly(fieldId);
// Sanity: the caller's GET view is masked.
const masked = await getRawPolicyExpression(page, policyId);
expect(masked).toContain('Alpha');
expect(masked).toContain('--------');
expect(masked).not.toContain('Bravo');
expect(masked).not.toContain('Charlie');
// Attempt to overwrite the masked rule with the bare deny-all sentinel.
const {status, body} = await resubmitPolicyExpression(page, policyId, 'false');
expect(status, `PUT re-submitting "false" returned ${status}`).toBe(403);
expect(body?.id).toBe('app.pap.save_policy.masked_condition_deleted');
// The stored expression must be untouched — direct DB read still has originals.
const rawExpression = (await getStoredPolicyRuleExpressions(policyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
expect(rawExpression).not.toBe('false');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* masking_db_setup.ts — direct DB helpers for the masking E2E suite.
*
* Why direct DB access:
* - access_mode=shared_only requires protected=true, which requires
* source_plugin_id (plugin-only). There is no admin/sysadmin bypass via API.
* - The masking feature flag is loaded at server boot and cannot be flipped at
* runtime, so going through the API would return the masked view and we'd
* have no way to verify what was actually persisted.
*
* Helpers use a one-shot `pg.Client` so we don't keep a connection pool open
* for the lifetime of the test run, and use parameterized queries throughout.
*
* Connects via testConfig.postgresUrl, which resolves to the fixed localhost:5432 default in
* `external` mode (docker-compose's mapped port), or the Testcontainers-assigned host port in
* `testcontainers` mode.
*/
import {Client} from 'pg';
import {testConfig} from '@mattermost/playwright-lib';
async function runQuery<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
const client = new Client({connectionString: testConfig.postgresUrl});
await client.connect();
try {
const result = await client.query(sql, params);
return result.rows as T[];
} finally {
await client.end();
}
}
/**
* Set a CPA field's access_mode to 'shared_only' directly in the DB.
* Bypasses API validation (which requires source_plugin_id for protected fields).
*/
export async function setFieldAsSharedOnly(fieldId: string): Promise<void> {
await setFieldAccessMode(fieldId, 'shared_only');
}
/**
* Set a CPA field's access_mode to 'source_only' directly in the DB.
* Bypasses API validation (which requires source_plugin_id for protected fields).
*/
export async function setFieldAsSourceOnly(fieldId: string): Promise<void> {
await setFieldAccessMode(fieldId, 'source_only');
}
/**
* Set the field back to public (removes the access_mode attr).
*/
export async function setFieldAsPublic(fieldId: string): Promise<void> {
await setFieldAccessMode(fieldId, '');
}
/**
* Read a policy's rule expressions straight from the AccessControlPolicies table.
* Bypasses the API masking pipeline entirely — what you get is what's persisted.
* Returns an array of rule expressions in storage order.
*/
export async function getStoredPolicyRuleExpressions(policyId: string): Promise<string[]> {
if (!/^[a-z0-9]{26}$/.test(policyId)) {
throw new Error(
`getStoredPolicyRuleExpressions: refusing to use untrusted policy id ${JSON.stringify(policyId)}`,
);
}
const rows = await runQuery<{expression: string | null}>(
`SELECT rule->>'expression' AS expression
FROM AccessControlPolicies, jsonb_array_elements(Data->'rules') AS rule
WHERE ID = $1`,
[policyId],
);
return rows.map((r) => (r.expression ?? '').trim()).filter((s) => s.length > 0);
}
/**
* Hard-delete a CPA field directly in the DB by setting deleteat.
* Use this instead of the API for fields that were flipped to protected=true
* via setFieldAsSharedOnly / setFieldAsSourceOnly — the API rejects deletes
* for protected fields (403), so calling it from a finally block silently
* leaves the field behind and the 200-field global limit fills up over time.
*/
export async function deleteFieldFromDB(fieldId: string): Promise<void> {
if (!/^[a-z0-9]{26}$/.test(fieldId)) {
throw new Error(`deleteFieldFromDB: refusing to use untrusted field id ${JSON.stringify(fieldId)}`);
}
await runQuery(
`UPDATE propertyfields
SET deleteat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE id = $1`,
[fieldId],
);
}
/**
* Soft-delete all CPA fields whose name starts with the given prefix.
* Used in beforeAll to purge orphaned test fields from previous failed runs,
* including protected ones that the API cannot delete.
*/
export async function purgeFieldsByPrefix(prefix: string): Promise<void> {
if (!/^[A-Za-z0-9_-]+$/.test(prefix)) {
throw new Error(`purgeFieldsByPrefix: refusing untrusted prefix ${JSON.stringify(prefix)}`);
}
await runQuery(
`UPDATE propertyfields
SET deleteat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE name LIKE $1
AND deleteat = 0`,
[`${prefix}%`],
);
}
async function setFieldAccessMode(fieldId: string, accessMode: string): Promise<void> {
if (!/^[a-z0-9]{26}$/.test(fieldId)) {
throw new Error(`setFieldAccessMode: refusing to use untrusted field id ${JSON.stringify(fieldId)}`);
}
if (!/^[a-z_]*$/.test(accessMode)) {
throw new Error(`setFieldAccessMode: refusing untrusted access mode ${JSON.stringify(accessMode)}`);
}
if (accessMode === '') {
// Public = remove both access_mode and protected keys, and clear the protected column.
await runQuery(
`UPDATE propertyfields
SET attrs = (COALESCE(attrs, '{}'::jsonb) - 'access_mode' - 'protected'),
protected = false,
updateat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE id = $1`,
[fieldId],
);
return;
}
await runQuery(
`UPDATE propertyfields
SET attrs = jsonb_set(
jsonb_set(COALESCE(attrs, '{}'::jsonb), '{access_mode}', to_jsonb($2::text)),
'{protected}',
'true'::jsonb
),
protected = true,
updateat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE id = $1`,
[fieldId, accessMode],
);
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import {deleteFieldFromDB} from './masking_db_setup';
export async function enableMaskingFlag(client: Client4): Promise<void> {
const config = await client.getConfig();
config.FeatureFlags = config.FeatureFlags || {};
(config.FeatureFlags as any).AttributeValueMasking = true;
await client.updateConfig(config);
}
export async function disableMaskingFlag(client: Client4): Promise<void> {
const config = await client.getConfig();
config.FeatureFlags = config.FeatureFlags || {};
(config.FeatureFlags as any).AttributeValueMasking = false;
await client.updateConfig(config);
}
export async function createMaskingTextField(client: Client4, fieldName: string): Promise<string> {
const url = `${client.getBaseRoute()}/custom_profile_attributes/fields`;
const created = await (client as any).doFetch(url, {
method: 'POST',
body: JSON.stringify({
name: fieldName,
type: 'text',
attrs: {
sort_order: 99,
managed: 'admin',
visibility: 'when_set',
},
}),
});
return created.id as string;
}
export async function createMaskingMultiselectField(
client: Client4,
fieldName: string,
options: string[],
): Promise<string> {
const url = `${client.getBaseRoute()}/custom_profile_attributes/fields`;
const created = await (client as any).doFetch(url, {
method: 'POST',
body: JSON.stringify({
name: fieldName,
type: 'multiselect',
attrs: {
sort_order: 99,
managed: 'admin',
visibility: 'when_set',
options: options.map((name) => ({name, color: ''})),
},
}),
});
return created.id as string;
}
/**
* Delete a CPA field by ID. Tries the API first; falls back to a direct DB
* soft-delete for fields that were flipped to protected=true via
* setFieldAsSharedOnly / setFieldAsSourceOnly (the API returns 403 for those).
* Never throws.
*/
export async function deleteCPAField(client: Client4, fieldId: string): Promise<void> {
if (!fieldId) {
return;
}
try {
await (client as any).doFetch(`${client.getBaseRoute()}/custom_profile_attributes/fields/${fieldId}`, {
method: 'DELETE',
});
} catch {
try {
await deleteFieldFromDB(fieldId);
} catch {
// best-effort
}
}
}
export async function deletePolicy(client: Client4, policyId: string): Promise<void> {
if (!policyId) {
return;
}
try {
await (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies/${policyId}`, {
method: 'DELETE',
});
} catch {
// best-effort
}
}
export async function setUserAttribute(client: Client4, userId: string, fieldId: string, value: string): Promise<void> {
await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value});
}
/**
* Create a membership policy using the Advanced (CEL) editor in the UI.
* Does NOT add channels so there is no "Apply policy" gate to click through.
* Returns the policy ID extracted from the URL after saving.
*/
export async function createPolicyWithCEL(page: Page, name: string, celExpression: string): Promise<string> {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const addPolicyBtn = page.getByRole('button', {name: 'Add policy'});
await addPolicyBtn.waitFor({state: 'visible', timeout: 15000});
await addPolicyBtn.click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(name);
const advancedBtn = page.getByRole('button', {name: /advanced/i});
await advancedBtn.waitFor({state: 'visible', timeout: 5000});
await advancedBtn.click();
await page.waitForTimeout(1000);
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.waitFor({state: 'visible', timeout: 5000});
await editorLines.click({force: true});
await page.waitForTimeout(300);
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.keyboard.type(celExpression, {delay: 10});
await page.waitForTimeout(1000);
const saveBtn = page.getByRole('button', {name: 'Save'});
await saveBtn.waitFor({state: 'visible', timeout: 5000});
const savePromise = page.waitForResponse(
(resp) =>
/\/api\/v4\/access_control_policies(\/[A-Za-z0-9]+)?$/.test(resp.url()) &&
resp.request().method() === 'PUT' &&
resp.ok(),
{timeout: 15000},
);
await saveBtn.click();
const saveResp = await savePromise;
const saved = await saveResp.json();
await page.waitForLoadState('networkidle');
const id = (saved?.id ?? saved?.ID ?? '') as string;
if (!/^[A-Za-z0-9]{26}$/.test(id)) {
throw new Error(
`createPolicyWithCEL: save response did not include a valid policy id (got ${JSON.stringify(id)})`,
);
}
return id;
}
/**
* Navigate to the membership-policies list and open the editor for the named policy.
*/
export async function openExistingPolicy(page: Page, policyName: string): Promise<void> {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 10000});
const searchResponse = page.waitForResponse(
(resp) =>
/\/api\/v4\/access_control_policies\/search$/.test(resp.url()) &&
resp.request().method() === 'POST' &&
resp.ok(),
{timeout: 15000},
);
await searchInput.fill(policyName);
await searchResponse.catch(() => {
// debounced search may settle from cache
});
await page.waitForLoadState('networkidle');
const policyRow = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRow.waitFor({state: 'visible', timeout: 20000});
await policyRow.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
}
export async function getRawPolicyExpression(page: Page, policyId: string): Promise<string> {
const data = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.json();
}, policyId);
return (data?.rules?.[0]?.expression ?? '') as string;
}
/**
* Re-submit an existing policy with rule[0].expression overwritten by
* newExpression, using the logged-in caller's own browser session (so per-caller
* masking applies). Performs the GET -> modify -> PUT round-trip a real client
* would: fetch the (possibly masked) policy, change one rule's expression, and
* save it back. Returns the HTTP status and parsed body so callers can assert
* the server's response (e.g. a fail-closed 403).
*/
export async function resubmitPolicyExpression(
page: Page,
policyId: string,
newExpression: string,
): Promise<{status: number; body: any}> {
return page.evaluate(
async ({id, expr}: {id: string; expr: string}) => {
const getResp = await fetch(`/api/v4/access_control_policies/${id}`, {
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
const policy = await getResp.json();
if (Array.isArray(policy?.rules) && policy.rules.length > 0) {
policy.rules[0].expression = expr;
}
const putResp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(policy),
});
const body = await putResp.json().catch(() => ({}));
return {status: putResp.status, body};
},
{id: policyId, expr: newExpression},
);
}
export async function searchPoliciesExpression(page: Page, term: string): Promise<string> {
const data = await page.evaluate(async (t: string) => {
const resp = await fetch('/api/v4/access_control_policies/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({term: t}),
});
return resp.json();
}, term);
const policies = data?.policies ?? (Array.isArray(data) ? data : []);
return (policies[0]?.rules?.[0]?.expression ?? '') as string;
}
/**
* Extract the policy ID from the current URL after the editor has opened.
* The route is `/admin_console/system_attributes/membership_policies/edit_policy/{id}`
*/
export async function getPolicyIdFromURL(page: Page): Promise<string> {
const url = page.url();
const editMatch = url.match(/edit_policy\/([A-Za-z0-9]{26})(?:[/?#]|$)/);
if (editMatch) {
return editMatch[1];
}
const fallback = url.match(/membership_policies\/([A-Za-z0-9]{26})/);
if (fallback) {
return fallback[1];
}
throw new Error(`getPolicyIdFromURL: could not extract policy id from URL ${JSON.stringify(url)}`);
}

View File

@@ -0,0 +1,485 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
getPolicyIdFromURL,
openExistingPolicy,
setUserAttribute,
} from './masking_helpers';
import {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingTE';
test.describe('Attribute-Value Masking - Table Editor', {tag: ['@abac', '@abac_masking']}, () => {
// Purge any orphaned MaskingTE* CPA fields left by previous failed runs so we
// don't hit the 200-field global limit mid-suite.
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-1: Full masking round-trip in Simple editor', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha" — Bravo and Charlie will be masked for them
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
// shared_only must flip AFTER the policy save: validatePolicyExpressionValues would
// otherwise reject values the caller does not hold. Flipping now means the policy
// is created against a public field, then masking applies on the next load.
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Alpha chip must be visible (caller holds it)
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
// Masked chip must be visible (Bravo + Charlie are hidden)
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Bravo and Charlie chips must NOT appear in plain text
await expect(page.locator('.select__multi-value').filter({hasText: 'Bravo'})).not.toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Charlie'})).not.toBeVisible();
// Warning banner must appear
await expect(page.locator('text="This policy contains restricted values"')).toBeVisible();
// Attribute selector on the row must be locked (has 'disabled' class)
const attributeSelector = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await expect(attributeSelector).toHaveClass(/disabled/);
// Test-access-rule button must be disabled when policy has masked values
const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'});
if (await testRulesBtn.isVisible({timeout: 3000})) {
await expect(testRulesBtn).toBeDisabled();
}
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-2: Caller with masked values can save; hidden values are preserved by merge', async ({pw}) => {
// Validates that callers with masked values CAN save changes. Merge-on-save
// re-injects hidden values so Bravo and Charlie survive even though the caller
// only sees and submits Alpha. Save button is enabled (not gated on masked state).
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
const storedPolicyId = await getPolicyIdFromURL(page);
// Alpha visible, Bravo+Charlie masked
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
const saveBtn = page.getByRole('button', {name: 'Save'});
// Dirty the form via the policy name field. Masked rows are read-only so
// we can't remove/add chips. The merge-on-save invariant we're testing
// doesn't depend on how the form is dirtied.
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.fill(policyName + ' (edited)');
await page.waitForTimeout(300);
await saveBtn.click();
await page.waitForLoadState('networkidle');
// Verify via DB: Bravo + Charlie preserved by merge-on-save
const rawExpression = (await getStoredPolicyRuleExpressions(storedPolicyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-3: Row-remove button is disabled on masked rows', async ({pw}) => {
// The trash/remove button on a masked row is disabled — a caller with
// masked values cannot delete individual rows, matching the Save/Delete
// buttons which are also disabled when masked values are present.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Row-remove (trash) button must be disabled on the masked row
const removeRowBtn = page
.locator('button[aria-label="Remove row"], button.table-editor__row-remove')
.first();
await removeRowBtn.waitFor({state: 'visible', timeout: 5000});
await expect(removeRowBtn).toBeDisabled();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-4: Self-inclusion failure blocks save (caller has full visibility)', async ({pw}) => {
// Self-inclusion is only checked when the caller holds ALL values in the policy
// (no masked values). If masked values are present the 403 block fires first
// and the Save button is disabled. This test uses a single-value policy so the
// caller has full visibility, then removes their own satisfying value.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha"; policy has ONLY ["Alpha"] — no masked values
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
await adminClient.addToTeam(team.id, adminUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(page, policyName, `user.attributes.${fieldName} in ["Alpha"]`);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Alpha visible, no masked chip — caller has full visibility
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
const saveBtn = page.getByRole('button', {name: 'Save'});
// Remove Alpha — now the condition has no values (empty)
const alphaChip = page.locator('.select__multi-value').filter({hasText: 'Alpha'});
await alphaChip.locator('.select__multi-value__remove').click();
await page.waitForTimeout(300);
await saveBtn.click();
await page.waitForTimeout(2000);
// An error message about self-inclusion should appear
const errorMsg = page.locator('text=/do not satisfy|self.inclusion|condition/i').first();
await expect(errorMsg).toBeVisible({timeout: 8000});
// Reload — Alpha should still be in the stored policy (save was blocked)
await page.reload();
await page.waitForLoadState('networkidle');
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-5: Non-held value rejected via direct API', async ({pw}) => {
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Try to create a policy containing a non-held value ("Delta") via direct API
const statusWithDelta = await page.evaluate(
async ({fieldName: fn}: {fieldName: string}) => {
const resp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
name: `Illegal ${Date.now()}`,
type: 'member',
rules: [{expression: `user.attributes.${fn} in ["Alpha", "Delta"]`}],
}),
});
return resp.status;
},
{fieldName},
);
// Server must reject with 400 — "Delta" is not a held value
expect(statusWithDelta).toBe(400);
// Also verify that the masked placeholder literal is rejected
const statusWithMasked = await page.evaluate(
async ({fieldName: fn}: {fieldName: string}) => {
const resp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
name: `Illegal ${Date.now()}`,
type: 'member',
rules: [{expression: `user.attributes.${fn} in ["Alpha", "--------"]`}],
}),
});
return resp.status;
},
{fieldName},
);
expect(statusWithMasked).toBe(400);
} finally {
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-6: CEL editor is read-only when policy has masked values', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Switch to Advanced (CEL) mode
const advancedBtn = page.getByRole('button', {name: /advanced/i});
await advancedBtn.waitFor({state: 'visible', timeout: 5000});
await advancedBtn.click();
await page.waitForTimeout(1000);
// Monaco editor must be read-only — verify functionally: capture the current text,
// attempt to type, and assert the content is unchanged.
const monacoEditor = page.locator('.monaco-editor').first();
await monacoEditor.waitFor({state: 'visible', timeout: 5000});
const viewLines = monacoEditor.locator('.view-lines').first();
const before = (await viewLines.textContent()) ?? '';
await monacoEditor.locator('textarea.inputarea').first().focus();
await page.keyboard.press('End');
await page.keyboard.type('xyz');
await page.waitForTimeout(300);
const after = (await viewLines.textContent()) ?? '';
expect(after).toBe(before);
// There should be a notice/banner about restricted values in CEL mode
const celNotice = page.locator('text=/restricted values|read.only/i').first();
await expect(celNotice).toBeVisible({timeout: 5000});
// Test-access-rule button must be disabled in CEL mode with masked values
const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'});
if (await testRulesBtn.isVisible({timeout: 3000})) {
await expect(testRulesBtn).toBeDisabled();
}
// Switch back to Simple mode — masked chip is still present
const simpleBtn = page.getByRole('button', {name: /simple/i});
if (await simpleBtn.isVisible({timeout: 3000})) {
await simpleBtn.click();
await page.waitForTimeout(500);
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
}
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,409 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingMultiselectField,
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
openExistingPolicy,
setUserAttribute,
} from './masking_helpers';
import {purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingVT';
test.describe('Attribute-Value Masking - Visibility and Text Fields', {tag: ['@abac', '@abac_masking']}, () => {
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-7: Caller holding all policy values sees them all unmasked', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha" and the policy contains ONLY "Alpha"
// → caller holds ALL values in the condition → nothing is masked
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(page, policyName, `user.attributes.${fieldName} in ["Alpha"]`);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Alpha visible
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
// No masked chip — caller holds all values
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
// No warning banner
await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible();
// Attribute selector is NOT locked
const attributeSelector = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await expect(attributeSelector).not.toHaveClass(/disabled/);
// Test access rule button should be enabled
const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'});
await expect(testRulesBtn).toBeVisible();
await expect(testRulesBtn).toBeEnabled();
// CEL mode is editable (no read-only)
const advancedBtn = page.getByRole('button', {name: /advanced/i});
await expect(advancedBtn).toBeVisible();
await advancedBtn.click();
await page.waitForTimeout(1000);
const monacoEditor = page.locator('.monaco-editor').first();
await expect(monacoEditor).toBeVisible();
const ariaReadOnly = await monacoEditor.getAttribute('aria-readonly');
expect(ariaReadOnly).not.toBe('true');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-8: New policy creation has no masking', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Navigate to New Policy form
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
// A fresh editor must show no masked chip and no warning banner
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible();
// Add a rule row
const addAttributeBtn = page.getByRole('button', {name: /add attribute/i});
await expect(addAttributeBtn).toBeVisible();
await expect(addAttributeBtn).toBeEnabled();
await addAttributeBtn.click();
await page.waitForTimeout(500);
// Still no masked chip after adding a blank row
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
// Attribute selector is NOT locked on a new row
const attributeSelector = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await expect(attributeSelector).not.toHaveClass(/disabled/);
} finally {
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-9: Masked row is fully read-only', async ({pw}) => {
// The masked row's value selector is locked — callers cannot add or remove values
// through it. This is intentional: any direct modification could silently drop
// hidden values, and the merge logic gates write-path edits on shared_only fields.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Masked state visible
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Value selector on the masked row must be disabled
const valueSelector = page.locator('[data-testid="valueSelectorMenuButton"]').first();
await expect(valueSelector).toBeVisible({timeout: 3000});
await expect(valueSelector).toBeDisabled();
// Attribute selector locked
await expect(page.locator('[data-testid="attributeSelectorMenuButton"]').first()).toHaveClass(/disabled/);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-10: Text field masking with "in" operator', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// "Alpha" chip visible (held); "Bravo" and "Charlie" are masked
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Bravo'})).not.toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Charlie'})).not.toBeVisible();
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Attribute selector on the masked row is locked
await expect(page.locator('[data-testid="attributeSelectorMenuButton"]').first()).toHaveClass(/disabled/);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-11: Text field masking with single-value operator (value not held)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
// adminUser holds "Building 1"; policy value is "Building 7" (not held)
const fieldName = `${fieldPrefix}Loc_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Building 1');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy: Location != "Building 7"
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} != "Building 7"`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// "Building 7" is not held by the admin → it should be masked in some form
await expect(page.locator('text="Building 7"')).not.toBeVisible();
// The row should still show the masked state: either masked chip or read-only input
const maskedState = page.locator(
'.select__multi-value--masked, input[disabled], .values-editor__simple-input[disabled]',
);
await expect(maskedState).toBeVisible({timeout: 5000});
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-22: Fully-masked hasAnyOf row displays correct operator', async ({pw}) => {
// Regression test for: when a caller holds none of the values in a
// hasAnyOf condition, all values are replaced by a single masked-token
// sentinel. The masked expression re-parses to a standalone "tok in attr"
// which mergeMultiselectConditions promotes to hasAllOf — showing the wrong
// operator in the table editor. The fix emits a duplicate-token OR to
// preserve hasAnyOf semantics through the re-parse cycle.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Team_${pw.random.id()}`;
const fieldId = await createMaskingMultiselectField(adminClient, fieldName, ['Alpha', 'Bravo']);
fieldIds.push(fieldId);
// adminUser holds NONE of the values — the entire condition is fully masked.
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy uses hasAnyOf: ("Alpha" in attr || "Bravo" in attr)
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`("Alpha" in user.attributes.${fieldName} || "Bravo" in user.attributes.${fieldName})`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Only the masked chip is visible — caller holds no values.
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).not.toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Bravo'})).not.toBeVisible();
// The operator selector on the masked row must show "has any of", NOT "has all of".
const operatorBtn = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
await operatorBtn.waitFor({state: 'visible', timeout: 10000});
await expect(operatorBtn).toContainText('has any of');
await expect(operatorBtn).not.toContainText('has all of');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
createUserWithAttributes,
getAdminClient,
TestBrowser,
getRandomId,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
ensureUserAttributes,
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
waitForPolicySyncJob,
getJobDetailsFromRecentJobs,
enableUserManagedAttributes,
assertAccessControlAutocompleteContains,
} from '../support';
/**
* ABAC Policies - Advanced Policies
*
* The previous monolithic MM-T5785 and MM-T5786 tests each did 3–5 independent
* assertions wrapped around one very-expensive sync-job cycle. They are now
* split into separate `test(...)` blocks — either sharing a single beforeAll
* (MM-T5785) or split into one-operator-per-test (MM-T5786) so the whole
* test-file parallelises cleanly under sharding.
*/
test.describe('ABAC Policies - Advanced Policies - MM-T5785 all attribute types (auto-add)', () => {
let sharedAdminClient: any;
let user1: Awaited<ReturnType<typeof createUserWithAttributes>>; // qualifying, NOT in channel
let user2: Awaited<ReturnType<typeof createUserWithAttributes>>; // qualifying, IN channel
let user3: Awaited<ReturnType<typeof createUserWithAttributes>>; // non-qualifying, IN channel
let privateChannel: any;
let licensed = true;
test.beforeAll(async ({browser}) => {
test.setTimeout(240000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
sharedAdminClient = adminClient;
// License gate
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await ensureUserAttributes(adminClient);
const suffix = getRandomId();
const team = await adminClient.createTeam({
name: `abac-${suffix}`,
display_name: `ABAC ${suffix}`,
type: 'O',
} as any);
// Three users with different attribute combinations
user1 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
user2 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
user3 = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user1.id);
await adminClient.addToTeam(team.id, user2.id);
await adminClient.addToTeam(team.id, user3.id);
// Attribute indexing settle
await new Promise((resolve) => setTimeout(resolve, 2000));
privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(user2.id, privateChannel.id);
await adminClient.addToChannel(user3.id, privateChannel.id);
// Drive policy creation via the system-console UI exactly once.
const tb = new TestBrowser(browser);
try {
const {systemConsolePage} = await tb.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `Multi-Attr Policy ${getRandomId()}`;
const celExpression = 'user.attributes.Department == "Engineering"';
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression,
autoSync: true,
channels: [privateChannel.display_name],
});
await systemConsolePage.page.waitForTimeout(1000);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyElementId = await policyRow.getAttribute('id');
const policyId = policyElementId?.replace('customDescription-', '');
if (!policyId) {
throw new Error('Could not get policy ID');
}
await searchInput.clear();
await activatePolicy(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
const jobId1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 10, jobId1);
try {
await getJobDetailsFromRecentJobs(systemConsolePage.page, privateChannel.display_name);
} catch {
// non-fatal
}
// Optional extra sync if user1 not added yet
const added = await verifyUserInChannel(adminClient, user1.id, privateChannel.id);
if (!added) {
const jobId2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 10, jobId2);
await systemConsolePage.page.waitForTimeout(2000);
}
} finally {
await tb.close().catch(() => {});
}
});
test('MM-T5785_a auto-adds qualifying user who was not in channel', async () => {
test.setTimeout(60000);
test.skip(!licensed, 'No ABAC license');
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, user1.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'user1 should be auto-added to channel',
})
.toBe(true);
});
test('MM-T5785_b keeps qualifying user who was already in channel', async () => {
test.setTimeout(60000);
test.skip(!licensed, 'No ABAC license');
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, user2.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'user2 should stay in channel',
})
.toBe(true);
});
test('MM-T5785_c auto-removes non-qualifying user who was in channel', async () => {
test.setTimeout(60000);
test.skip(!licensed, 'No ABAC license');
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, user3.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'user3 should be auto-removed from channel',
})
.toBe(false);
});
});
/**
* MM-T5786: Attribute-based access policy using operator variations in Simple mode.
*
* Splits the original test's 5 operator sequences into 5 independent tests
* that share a single beforeAll (which creates the shared team, attributes,
* engineer/sales users, and admin-logged-in system-console page). Each
* operator then adds its own channel + policy and verifies independently.
*
* Tests run serially within the same worker for this file, so mutations on
* the shared page don't race.
*/
test.describe('ABAC Policies - Advanced Policies - MM-T5786 operator variants', () => {
test.describe.configure({mode: 'serial'});
let sharedAdminClient: any;
let sharedTeamId: string;
let engineerUser: Awaited<ReturnType<typeof createUserForABAC>>;
let salesUser: Awaited<ReturnType<typeof createUserForABAC>>;
let deptFieldName: string;
let systemConsolePage: {page: Page};
let sharedTestBrowser: TestBrowser | null = null;
let licensed = true;
test.beforeAll(async ({browser}) => {
test.setTimeout(180000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
sharedAdminClient = adminClient;
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await enableUserManagedAttributes(adminClient);
const suffix = getRandomId();
deptFieldName = `MM5786_Dept_${suffix}`;
const attributeFields: CustomProfileAttribute[] = [
{name: deptFieldName, type: 'text', value: '', attrs: {managed: 'admin', visibility: 'when_set'}},
];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
await assertAccessControlAutocompleteContains(adminClient, [deptFieldName]);
engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, type: 'text', value: 'Engineering'},
]);
salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, type: 'text', value: 'Sales'},
]);
const teamSuffix = getRandomId();
const team = await adminClient.createTeam({
name: `abac-ops-${teamSuffix}`,
display_name: `ABAC-Ops ${teamSuffix}`,
type: 'O',
} as any);
sharedTeamId = team.id;
await adminClient.addToTeam(sharedTeamId, engineerUser.id);
await adminClient.addToTeam(sharedTeamId, salesUser.id);
sharedTestBrowser = new TestBrowser(browser);
const loggedIn = await sharedTestBrowser.login(adminUser);
systemConsolePage = loggedIn.systemConsolePage;
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
});
test.afterAll(async () => {
await sharedTestBrowser?.close().catch(() => {});
});
async function runOperatorCase(celExpression: string, namePrefix: string, searchTerm: string) {
const channel = await createPrivateChannelForABAC(sharedAdminClient, sharedTeamId);
await sharedAdminClient.addToChannel(salesUser.id, channel.id);
await navigateToABACPage(systemConsolePage.page);
const policyName = `${namePrefix} Policy ${getRandomId()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: celExpression.replaceAll('user.attributes.Department', `user.attributes.${deptFieldName}`),
autoSync: true,
channels: [channel.display_name],
});
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.fill(searchTerm);
// Wait for the exact policy row to appear instead of grabbing .first() blindly.
// Under parallel load the grid update may be delayed, and .first() can return a
// policy created by another concurrent test.
const policyRow = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
await expect
.poll(() => policyRow.isVisible(), {
timeout: 45_000,
intervals: [200, 500, 1000, 2000],
message: `policy row for "${policyName}" should appear in search results`,
})
.toBe(true);
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(sharedAdminClient, policyId);
const jobId3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 15, jobId3);
}
await searchInput.clear();
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, engineerUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'engineerUser should be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, salesUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'salesUser should not be in channel',
})
.toBe(false);
}
test('MM-T5786_a is-not (!=) operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department != "Sales"', 'IsNot', 'IsNot');
});
test('MM-T5786_b in operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department in ["Engineering", "DevOps"]', 'In', 'In Policy');
});
test('MM-T5786_c starts-with operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department.startsWith("Eng")', 'StartsWith', 'StartsWith');
});
test('MM-T5786_d ends-with operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department.endsWith("ing")', 'EndsWith', 'EndsWith');
});
test('MM-T5786_e contains operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department.contains("gineer")', 'Contains', 'Contains');
});
});
/**
* MM-T5787: Complex CEL expressions with || and grouping ().
* Kept as a single test because its three verifications are cheap once
* the single sync-job cycle completes.
*/
test.describe('ABAC Policies - Advanced Policies', () => {
test('MM-T5787 Test policy with complex rules in Advanced Mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const idSuffix = pw.random.id();
const deptFieldName = `MM5787_Dept_${idSuffix}`;
const locationFieldName = `MM5787_Loc_${idSuffix}`;
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{
name: deptFieldName,
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set'},
},
{
name: locationFieldName,
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set'},
},
]);
await assertAccessControlAutocompleteContains(adminClient, [deptFieldName, locationFieldName]);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, value: 'Engineering', type: 'text'},
{name: locationFieldName, value: 'Office', type: 'text'},
]);
const salesRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, value: 'Sales', type: 'text'},
{name: locationFieldName, value: 'Remote', type: 'text'},
]);
const salesOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, value: 'Sales', type: 'text'},
{name: locationFieldName, value: 'Office', type: 'text'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesRemoteUser.id);
await adminClient.addToTeam(team.id, salesOfficeUser.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesOfficeUser.id, channel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
const policyName = `Complex Policy ${pw.random.id()}`;
const complexExpression = `user.attributes.${deptFieldName} == "Engineering" || (user.attributes.${deptFieldName} == "Sales" && user.attributes.${locationFieldName} == "Remote")`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: complexExpression,
autoSync: true,
channels: [channel.display_name],
});
await navigateToABACPage(systemConsolePage.page);
await systemConsolePage.page.waitForTimeout(500);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
const policyIdMatch = policyName.match(/([a-z0-9]+)$/i);
const searchTerm = policyIdMatch ? policyIdMatch[1] : policyName;
await searchInput.fill(searchTerm);
await systemConsolePage.page.waitForTimeout(500);
const foundPolicy = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
await expect
.poll(() => foundPolicy.isVisible(), {
timeout: 15_000,
message: `policy "${policyName}" should appear after search`,
})
.toBe(true);
const policyId = (await foundPolicy.getAttribute('id'))?.replace('customDescription-', '');
expect(policyId, 'policy row should expose id').toBeTruthy();
await activatePolicy(adminClient, policyId!);
const jobId4 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 5, jobId4);
await searchInput.clear();
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'engineerUser should be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'salesRemoteUser should be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesOfficeUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'salesOfficeUser should not be in channel',
})
.toBe(false);
});
});

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, navigateToABACPage, runSyncJob, verifyUserInChannel} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {
ensureUserAttributes,
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createAdvancedPolicy,
activatePolicy,
waitForPolicySyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5786 (1/5): "is not" (!=) operator — Department != "Sales" with auto-add
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
*/
test('MM-T5786 Test "is not" (!=) operator in Simple mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesUser.id, channel.id);
await ensureUserAttributes(adminClient);
const policyName = `IsNot Policy ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department != "Sales"',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department != "Sales"', [engineerUser.id]);
await activatePolicy(adminClient, policyId);
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
await expect
.poll(() => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be in channel after sync',
})
.toBe(true);
expect(await verifyUserInChannel(adminClient, salesUser.id, channel.id)).toBe(false);
});
/**
* MM-T5786 (2/5): "in" operator — Department in ["Engineering", "DevOps"] with auto-add
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
*/
test('MM-T5786 Test "in" operator in Simple mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesUser.id, channel.id);
await ensureUserAttributes(adminClient);
const policyName = `In Policy ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department in ["Engineering", "DevOps"]',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department in ["Engineering", "DevOps"]', [
engineerUser.id,
]);
await activatePolicy(adminClient, policyId);
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
await expect
.poll(() => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be in channel after sync',
})
.toBe(true);
expect(await verifyUserInChannel(adminClient, salesUser.id, channel.id)).toBe(false);
});
/**
* MM-T5786 (3/5): "starts with" operator — Department.startsWith("Eng") with auto-add
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
*/
test('MM-T5786 Test "starts with" operator in Simple mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesUser.id, channel.id);
await ensureUserAttributes(adminClient);
const policyName = `StartsWith Policy ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department.startsWith("Eng")',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department.startsWith("Eng")', [engineerUser.id]);
await activatePolicy(adminClient, policyId);
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
await expect
.poll(() => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be in channel after sync',
})
.toBe(true);
expect(await verifyUserInChannel(adminClient, salesUser.id, channel.id)).toBe(false);
});

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* E2E tests for MM-64357 in the Membership Policy editor.
*
* A rule value containing a quote character (e.g. the apostrophe in
* "Matt's Department") must still be recognized as a simple expression. Before
* the fix the quoted-value matcher forbade any inner quote, so such a rule was
* misclassified as complex: the "Switch to Simple Mode" toggle became disabled
* and the admin was trapped in Advanced mode after a save/reopen.
*
* These specs drive the rendered UI (button enabled/disabled state and the
* save -> reopen round-trip) rather than the helper functions directly, which
* is the coverage gap flagged in review.
*/
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
import {deleteCustomProfileAttributes} from '../../../channels/custom_profile_attributes/helpers';
const APOSTROPHE_VALUE = "Matt's Department";
test.describe('System Console - Membership Policy apostrophe values (MM-64357)', () => {
let adminClient: Client4;
let adminUser: UserProfile;
let field: UserPropertyField | undefined;
test.beforeEach(async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const clientInfo = await pw.getAdminClient();
adminClient = clientInfo.adminClient;
adminUser = clientInfo.adminUser!;
// # Create an admin-managed text attribute (admin-managed → usable in ABAC)
field = await adminClient.createCustomProfileAttributeField({
name: `department_${getRandomId()}`,
type: 'text',
attrs: {
sort_order: 0,
managed: 'admin',
},
} as any);
// # Make sure ABAC stays enabled (a concurrent initSetup can reset config)
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as any);
});
test.afterEach(async () => {
if (field) {
await deleteCustomProfileAttributes(adminClient, {
[field.id]: field,
__ownedIds: new Set([field.id]),
} as any);
field = undefined;
}
});
/**
* @objective A rule whose value contains an apostrophe survives a
* save/reopen round-trip: the stored expression rehydrates as a simple
* expression so the "Switch to Simple Mode" toggle stays enabled (the admin
* is not trapped in Advanced mode) and the table editor re-renders the same
* value.
*
* @precondition
* An admin-managed text attribute exists.
*/
test('round-trips an apostrophe "is" value and keeps the mode toggle switchable', {tag: '@abac'}, async ({pw}) => {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
const policyName = `Apostrophe RT ${getRandomId()}`;
let policyId: string | null = null;
try {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Create a policy and add a text attribute row
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyName);
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 10000});
if (await addAttributeButton.isDisabled()) {
await page.reload();
await page.waitForLoadState('networkidle');
await nameInput.fill(policyName);
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
}
await addAttributeButton.click();
// # Select the text attribute by name
if (!(await page.locator('[id^="attribute-selector-menu"]').isVisible({timeout: 2000}))) {
await page.locator('[data-testid="attributeSelectorMenuButton"]').first().click();
}
await page
.locator(`[id^="attribute-selector-menu"] li:has-text("${field!.name}")`)
.first()
.click({force: true});
// # Dismiss the attribute menu
await page.keyboard.press('Escape');
// * Verify the attribute menu is hidden
await expect(page.locator('[id^="attribute-selector-menu"]')).toBeHidden();
// * A text attribute defaults to the "is" operator
await expect(page.locator('[data-testid="operatorSelectorMenuButton"]').first()).toContainText('is');
// # Type a value containing an apostrophe
const valueInput = page.locator('.values-editor__simple-input, input[placeholder*="Add value" i]').first();
await valueInput.waitFor({state: 'visible', timeout: 10000});
await valueInput.fill(APOSTROPHE_VALUE);
await expect(valueInput).toHaveValue(APOSTROPHE_VALUE);
// # Save the policy
await page.getByRole('button', {name: 'Save'}).last().click();
await page.waitForLoadState('networkidle');
// # Reopen the policy from the list
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).first();
await expect(policyRow).toBeVisible({timeout: 10000});
const rowId = await policyRow.getAttribute('id');
policyId = rowId?.replace('customDescription-', '') ?? null;
await policyRow.click();
await page.waitForLoadState('networkidle');
// # Switch to Advanced mode so the "Switch to Simple Mode" toggle is present.
const toAdvanced = page.getByRole('button', {name: 'Switch to Advanced Mode'});
if (await toAdvanced.isVisible({timeout: 5000}).catch(() => false)) {
await expect(toAdvanced).toBeEnabled({timeout: 60_000});
await toAdvanced.click();
}
// * The stored apostrophe expression classifies as simple, so the
// toggle back to Simple mode stays enabled (before the fix it was
// disabled, trapping the admin in Advanced mode).
const toSimpleMode = page.getByRole('button', {name: 'Switch to Simple Mode'});
await expect(toSimpleMode).toBeVisible({timeout: 10_000});
await expect(toSimpleMode).toBeEnabled();
// # Return to Simple mode and confirm the value round-tripped intact
await toSimpleMode.click();
const reopenedValue = page
.locator('.values-editor__simple-input, input[placeholder*="Add value" i]')
.first();
await expect(reopenedValue).toHaveValue(APOSTROPHE_VALUE, {timeout: 10_000});
} finally {
// Clean up the policy. If we didn't capture the ID from the DOM (e.g. because
// save succeeded but navigation/assertion failed), search for it by name.
let idToDelete = policyId;
if (!idToDelete) {
try {
const searchResult = await adminClient.searchAccessControlPolicies(policyName, 'parent', '', 10);
const foundPolicy = searchResult.policies?.find((p) => p.name === policyName);
if (foundPolicy) {
idToDelete = foundPolicy.id;
}
} catch {
// Search failed; skip cleanup
}
}
if (idToDelete) {
await adminClient.deleteAccessControlPolicy(idToDelete).catch(() => {});
}
}
});
});

View File

@@ -0,0 +1,406 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
ensureUserAttributes,
createUserForABAC,
createPrivateChannelForABAC,
createBasicPolicy,
waitForLatestSyncJob,
} from '../support';
/**
* ABAC Policies - Channel Integration
* Tests for managing ABAC policies through Channel Configuration
*/
test.describe('ABAC Policies - Channel Integration', () => {
/**
* MM-T5788: Add attribute-based policy to a channel from Channel Configuration page
* @objective Verify that a policy can be added to a channel from the Channel Configuration page
*
* Steps:
* 1. As admin go to System Console > User Management > Channels
*/
test('MM-T5788 Add policy to channel from Channel Configuration page', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create users, channel, and policy
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Create satisfying user (Department=Engineering) - NOT in channel initially
const satisfyingUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
await adminClient.addToTeam(team.id, satisfyingUser.id);
// Create non-satisfying user (Department=Sales) - will be IN channel initially
const nonSatisfyingUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
await adminClient.addToTeam(team.id, nonSatisfyingUser.id);
// Create private channel and add non-satisfying user
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(nonSatisfyingUser.id, channel.id);
// Login and setup ABAC
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
// Create policy without channels (we'll link via Channel Config)
const policyName = `Channel Config Policy ${pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
});
// Get search term for policy
const policyIdMatch = policyName.match(/([a-z0-9]+)$/i);
const searchTerm = policyIdMatch ? policyIdMatch[1] : policyName;
// ============================================================
// STEP 1-2: Navigate to Channel Configuration
// ============================================================
await systemConsolePage.page.goto('/admin_console/user_management/channels');
await systemConsolePage.page.waitForLoadState('networkidle');
// Search and find our channel
const channelSearchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await channelSearchInput.fill(channel.display_name);
await systemConsolePage.page.waitForTimeout(1000);
// Verify channel shows "Manual Invites" management
const channelRow = systemConsolePage.page
.locator('.DataGrid_row')
.filter({hasText: channel.display_name})
.first();
// const managementText = await channelRow.textContent();
// Click Edit
await channelRow.getByText('Edit').click();
await systemConsolePage.page.waitForLoadState('networkidle');
// ============================================================
// STEP 3: Toggle on "Enable attribute based channel access"
// ============================================================
const abacToggle = systemConsolePage.page.locator('[data-testid="policy-enforce-toggle-button"]');
await abacToggle.waitFor({state: 'visible', timeout: 5000});
const isEnabled = await abacToggle.getAttribute('aria-pressed');
if (isEnabled !== 'true') {
await abacToggle.click();
}
await systemConsolePage.page.waitForTimeout(500);
// ============================================================
// STEP 4: Link to policy and Save
// ============================================================
// Click "Link to a policy"
const linkButton = systemConsolePage.page.locator('[data-testid="link-to-a-policy"]');
await linkButton.waitFor({state: 'visible', timeout: 5000});
await linkButton.click();
await systemConsolePage.page.waitForTimeout(500);
// Select policy in modal
const modal = systemConsolePage.page.locator('[role="dialog"]').filter({hasText: 'Select a Membership Policy'});
await modal.waitFor({state: 'visible', timeout: 5000});
const modalSearch = modal.locator('[data-testid="searchInput"]');
await modalSearch.fill(searchTerm);
await systemConsolePage.page.waitForTimeout(1000);
const policyOption = modal.locator('.DataGrid_row').filter({hasText: policyName}).first();
await policyOption.click();
await systemConsolePage.page.waitForTimeout(500);
// Save
await systemConsolePage.page.getByRole('button', {name: 'Save'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// ============================================================
// Run sync to apply policy
// ============================================================
await systemConsolePage.page.waitForTimeout(2000);
await navigateToABACPage(systemConsolePage.page);
const jobId1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 5, jobId1);
// ============================================================
// VERIFY: Channel membership
// ============================================================
// 1. Non-satisfying user should be REMOVED
const nonSatisfyingInChannel = await verifyUserInChannel(adminClient, nonSatisfyingUser.id, channel.id);
expect(nonSatisfyingInChannel).toBe(false);
// 2. Satisfying user should NOT be auto-added (per requirement)
const satisfyingInChannel = await verifyUserInChannel(adminClient, satisfyingUser.id, channel.id);
// Note: If implementation auto-adds, this will fail. Adjust if needed.
expect(satisfyingInChannel).toBe(false);
// 3. Satisfying user CAN be manually added
await adminClient.addToChannel(satisfyingUser.id, channel.id);
const afterManualAdd = await verifyUserInChannel(adminClient, satisfyingUser.id, channel.id);
expect(afterManualAdd).toBe(true);
// 4. Non-satisfying user CANNOT be added (blocked by policy)
let blocked = false;
try {
await adminClient.addToChannel(nonSatisfyingUser.id, channel.id);
} catch {
// Expected to fail - policy blocks non-qualifying users
blocked = true;
}
expect(blocked).toBe(true);
});
/**
* MM-T5789: Channel cannot use attribute-based policies if already constrained by LDAP group sync
*
* Preconditions:
* - At least one policy exists on the server
* - At least one channel configured to be constrained by LDAP group sync
*
* Step 1:
* 1. As admin go to System Console > User Management > Channels
* 2. Click a channel with Management = "Group Sync" (private channel)
* 3. Observe "Enable attribute based channel access" is NOT available
* 4. Toggle off "Sync Group Members", observe ABAC becomes available
*
* Step 2:
* 1. Go to System Console > User Management > Attribute-Based Access
* 2. Click a policy to edit it, click Add channels
* 3. Select a channel constrained by LDAP group sync
*
* Expected: ABAC not available for channels using LDAP group sync
*
* Test Data Note: Current UI behavior is uncertain - may allow save but channel
* not added, or may show error. This test observes and documents actual behavior.
*
* Implementation Note: We mock Group Sync by setting group_constrained=true via API.
* This works without LDAP - the server accepts it and UI shows "Group Sync".
*/
test('MM-T5789 Channel with LDAP group sync cannot use ABAC', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
// ===========================================
// PRECONDITION 1: Create a policy first
// ===========================================
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `ABAC-GroupSync-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
});
// ===========================================
// PRECONDITION 2: Create a Group Sync channel via API
// We mock Group Sync by setting group_constrained=true
// This works without LDAP configuration
// ===========================================
const groupSyncChannelName = `ABAC-GroupSync-${pw.random.id()}`;
const groupSyncChannel = await adminClient.createChannel({
team_id: team.id,
name: groupSyncChannelName.toLowerCase().replace(/[^a-z0-9]/g, ''),
display_name: groupSyncChannelName,
type: 'P', // Private
});
// Set group_constrained=true via API to mock Group Sync
await adminClient.patchChannel(groupSyncChannel.id, {
group_constrained: true,
} as any);
// ===========================================
// STEP 1: Navigate to Group Sync channel config
// ===========================================
await page.goto('/admin_console/user_management/channels');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Search for our channel
const searchInput = page.locator('input[placeholder*="Search" i]').first();
if (await searchInput.isVisible({timeout: 3000})) {
await searchInput.fill(groupSyncChannelName);
await page.waitForTimeout(1000);
}
// Verify channel shows as "Group Sync"
const channelRow = page.locator('.DataGrid_row').filter({hasText: groupSyncChannelName}).first();
await channelRow.waitFor({state: 'visible', timeout: 10000});
const rowText = await channelRow.textContent();
expect(rowText).toContain('Group Sync');
// Click Edit to open channel configuration
await channelRow.getByText('Edit').click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// STEP 1.3: Verify ABAC toggle is NOT available when Group Sync is enabled
const abacToggle = page.locator('[data-testid="policy-enforce-toggle-button"]');
const abacVisibleWithGroupSync = await abacToggle.isVisible({timeout: 5000}).catch(() => false);
expect(abacVisibleWithGroupSync).toBe(false);
// STEP 1.4: Toggle off Group Sync and verify ABAC becomes available
// Disable Group Sync via API (more reliable than UI toggle)
await adminClient.patchChannel(groupSyncChannel.id, {
group_constrained: false,
} as any);
// Reload page to see updated UI
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify ABAC toggle is now available
const abacToggleAfter = page.locator('[data-testid="policy-enforce-toggle-button"]');
const abacVisibleAfterDisable = await abacToggleAfter.isVisible({timeout: 5000}).catch(() => false);
expect(abacVisibleAfterDisable).toBe(true);
// Re-enable Group Sync for Step 2
await adminClient.patchChannel(groupSyncChannel.id, {
group_constrained: true,
} as any);
// ===========================================
// STEP 2: Try to add Group Sync channel to existing policy
// ===========================================
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Step 2.2: Click the policy to edit it
// Search for the policy first
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
// Click on the policy row (use text-based locator)
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Click Add channels button
const addChannelsButton = page.getByRole('button', {name: /add channel/i});
await addChannelsButton.waitFor({state: 'visible', timeout: 10000});
await addChannelsButton.click();
await page.waitForTimeout(1000);
// Step 2.3: Try to select the Group Sync channel
const channelModal = page.locator('[role="dialog"]').filter({hasText: /channel/i});
await channelModal.waitFor({state: 'visible', timeout: 5000});
// Search for the Group Sync channel
const modalSearchInput = channelModal.locator('[data-testid="searchInput"], input[type="text"]').first();
if (await modalSearchInput.isVisible({timeout: 3000})) {
await modalSearchInput.fill(groupSyncChannelName);
await page.waitForTimeout(1000);
}
// Document actual behavior (requirement notes uncertainty)
const channelRows = channelModal.locator('.DataGrid_row, .more-modal__row');
const rowCount = await channelRows.count();
if (rowCount === 0) {
// Group Sync channel is filtered out - good behavior
} else {
// Channel is shown - try to select it
const channelRowToSelect = channelRows.first();
await channelRowToSelect.textContent();
// Try to click/select the channel
await channelRowToSelect.click({timeout: 5000}).catch(() => {
// Ignore click errors
});
await page.waitForTimeout(500);
// Try to click Add button
const addButton = channelModal.getByRole('button', {name: 'Add'});
if (await addButton.isVisible({timeout: 3000})) {
const addButtonDisabled = await addButton.isDisabled();
if (addButtonDisabled) {
// Add button is disabled
} else {
await addButton.click();
await page.waitForTimeout(1000);
}
}
// Close modal
const closeButton = channelModal.getByRole('button', {name: /close|cancel|×/i});
if (await closeButton.isVisible({timeout: 2000})) {
await closeButton.click();
await page.waitForTimeout(500);
}
// Check if we're back on edit page - try to save
const saveButton = page.getByRole('button', {name: 'Save'});
if (await saveButton.isVisible({timeout: 3000})) {
const saveEnabled = await saveButton.isEnabled();
if (saveEnabled) {
await saveButton.click();
await page.waitForTimeout(2000);
// Check for error message
const errorMessage = page.locator('.error-message, [class*="error"], .alert-danger');
const hasError = await errorMessage.isVisible({timeout: 3000}).catch(() => false);
if (hasError) {
await errorMessage.textContent();
} else {
// Check if channel was actually added
}
}
}
}
});
});

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
waitForPolicySyncJob,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC Policies - Create Policies
* Tests for creating ABAC policies with different auto-add settings
*/
test.describe('ABAC Policies - Create Policies', () => {
test('MM-T5783 Create and test policy with auto-add disabled', async ({pw}) => {
// # Skip test if no license for ABAC
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create users and channel BEFORE creating policy
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
// Enable user-managed attributes
await enableUserManagedAttributes(adminClient);
// Define and create the Department attribute field
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
// Create 3 users as per test case:
// 1. satisfyingUserNotInChannel - Department=Engineering, NOT in channel initially
// 2. satisfyingUserInChannel - Department=Engineering, IN channel initially
// 3. nonSatisfyingUserInChannel - Department=Sales, IN channel initially
const satisfyingUserNotInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const satisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const nonSatisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add all users to the team
await adminClient.addToTeam(team.id, satisfyingUserNotInChannel.id);
await adminClient.addToTeam(team.id, satisfyingUserInChannel.id);
await adminClient.addToTeam(team.id, nonSatisfyingUserInChannel.id);
// Create private channel and add users 2 and 3 (but NOT user 1)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(satisfyingUserInChannel.id, privateChannel.id);
await adminClient.addToChannel(nonSatisfyingUserInChannel.id, privateChannel.id);
// Verify initial channel state
const initialUser1InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserNotInChannel.id,
privateChannel.id,
);
const initialUser2InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserInChannel.id,
privateChannel.id,
);
const initialUser3InChannel = await verifyUserInChannel(
adminClient,
nonSatisfyingUserInChannel.id,
privateChannel.id,
);
expect(initialUser1InChannel).toBe(false);
expect(initialUser2InChannel).toBe(true);
expect(initialUser3InChannel).toBe(true);
// ============================================================
// STEP 1-4: Login, navigate to ABAC, create policy with rule and channel
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and policy creation
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Use the working createBasicPolicy helper (same as MM-T5784)
const policyName = `Engineering Policy ${pw.random.id()}`;
const jobIdMM5783 = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // Auto-add DISABLED for this test
channels: [privateChannel.display_name],
});
// ============================================================
// STEP 3: Test Access Rule (navigate back to policy to test)
// ============================================================
// Navigate back to policy to test the access rule
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
const testResult = await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [satisfyingUserNotInChannel.username, satisfyingUserInChannel.username],
expectedNonMatchingUsers: [nonSatisfyingUserInChannel.username],
});
expect(testResult.expectedUsersMatch).toBe(true);
expect(testResult.unexpectedUsersMatch).toBe(false);
// Navigate back to ABAC page
await navigateToABACPage(systemConsolePage.page);
}
// Wait for sync job to complete (triggered by createBasicPolicy)
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobIdMM5783);
// ============================================================
// STEP 5-7: Verify channel membership after sync
// ============================================================
// Step 5: User who satisfies policy but NOT in channel → should NOT be auto-added
const user1AfterSync = await verifyUserInChannel(adminClient, satisfyingUserNotInChannel.id, privateChannel.id);
expect(user1AfterSync).toBe(false); // NOT auto-added because auto-add is FALSE
// Step 6: User who satisfies policy and IS in channel → no change (stays in channel)
const user2AfterSync = await verifyUserInChannel(adminClient, satisfyingUserInChannel.id, privateChannel.id);
expect(user2AfterSync).toBe(true); // Stays in channel
// Step 7: User who does NOT satisfy policy and IS in channel → auto-removed
const user3AfterSync = await verifyUserInChannel(adminClient, nonSatisfyingUserInChannel.id, privateChannel.id);
expect(user3AfterSync).toBe(false); // AUTO-REMOVED
// ============================================================
// STEP 8: Admin can manually add the satisfying user to channel
// Validate: satisfying user CAN be added, non-satisfying user CANNOT
// ============================================================
// 8a. Add user who SATISFIES the policy - should succeed
await adminClient.addToChannel(satisfyingUserNotInChannel.id, privateChannel.id);
const user1AfterManualAdd = await verifyUserInChannel(
adminClient,
satisfyingUserNotInChannel.id,
privateChannel.id,
);
expect(user1AfterManualAdd).toBe(true); // Successfully added by admin
// 8b. Try to add user who does NOT satisfy the policy - should FAIL
try {
await adminClient.addToChannel(nonSatisfyingUserInChannel.id, privateChannel.id);
} catch {
// Expected to fail - policy prevents non-compliant users
}
// Verify the non-satisfying user is NOT in the channel
const user3AfterAttempt = await verifyUserInChannel(
adminClient,
nonSatisfyingUserInChannel.id,
privateChannel.id,
);
expect(user3AfterAttempt).toBe(false); // Policy prevents non-compliant users
});
/**
* MM-T5784: Attribute-based access policy created in System Console controls access as specified
* (one attribute, = is, with auto-add)
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5784.md
*
* Test Steps:
* 1. As system admin, go to ABAC page, click Add policy, enter name, set Auto-add = TRUE
* 2. Select policy values: Attribute, Operator, and Value (just one)
* 3. Click Test Access Rule, observe users who satisfy the policy are listed
* 4. Click Add channels and select a channel, then save
* 5. User who satisfies policy but NOT in channel → should be AUTO-ADDED
* 6. User who satisfies policy and IS in channel → no change (stays in channel)
* 7. User who does NOT satisfy policy and IS in channel → auto-removed
*
* Expected:
* - User who satisfies the policy is auto-added
* - User who does not satisfy the policy is auto-removed
*/
test('MM-T5784 Create and test policy with auto-add enabled', async ({pw}) => {
// Increase timeout for this complex test to prevent trace file race conditions
test.setTimeout(120000); // 2 minutes instead of default 1 minute
// # Skip test if no license for ABAC
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create users and channel BEFORE creating policy
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
// Enable user-managed attributes
await enableUserManagedAttributes(adminClient);
// Define and create the Department attribute field
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
// Create 3 users as per test case:
// 1. satisfyingUserNotInChannel - Department=Engineering, NOT in channel initially
// 2. satisfyingUserInChannel - Department=Engineering, IN channel initially
// 3. nonSatisfyingUserInChannel - Department=Sales, IN channel initially
const satisfyingUserNotInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const satisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const nonSatisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add all users to the team
await adminClient.addToTeam(team.id, satisfyingUserNotInChannel.id);
await adminClient.addToTeam(team.id, satisfyingUserInChannel.id);
await adminClient.addToTeam(team.id, nonSatisfyingUserInChannel.id);
// Wait for user attributes to be indexed before creating policy
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create private channel and add users 2 and 3 (but NOT user 1)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(satisfyingUserInChannel.id, privateChannel.id);
await adminClient.addToChannel(nonSatisfyingUserInChannel.id, privateChannel.id);
// Verify initial channel state
const initialUser1InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserNotInChannel.id,
privateChannel.id,
);
const initialUser2InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserInChannel.id,
privateChannel.id,
);
const initialUser3InChannel = await verifyUserInChannel(
adminClient,
nonSatisfyingUserInChannel.id,
privateChannel.id,
);
expect(initialUser1InChannel).toBe(false);
expect(initialUser2InChannel).toBe(true);
expect(initialUser3InChannel).toBe(true);
// ============================================================
// STEP 1-4: Login, navigate to ABAC, create policy with auto-add TRUE
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
// Use createBasicPolicy with autoSync: true
const policyName = `Auto-Add Policy ${pw.random.id()}`;
const jobIdMM5784 = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add ENABLED for this test
channels: [privateChannel.display_name],
});
// ============================================================
// STEP 3: Test Access Rule (navigate back to policy to test)
// ============================================================
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
const testResult = await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [satisfyingUserNotInChannel.username, satisfyingUserInChannel.username],
expectedNonMatchingUsers: [nonSatisfyingUserInChannel.username],
});
expect(testResult.expectedUsersMatch).toBe(true);
expect(testResult.unexpectedUsersMatch).toBe(false);
await navigateToABACPage(systemConsolePage.page);
}
// Wait for initial sync job to complete
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobIdMM5784);
// Get policy ID and activate it for auto-add to work
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyElementId = await policyRow.getAttribute('id');
const policyId = policyElementId?.replace('customDescription-', '');
if (!policyId) {
throw new Error('Could not get policy ID');
}
await searchInput.clear();
// Activate the policy so auto-add works
await activatePolicy(adminClient, policyId);
// Run sync job with active policy; poll by policyId to avoid picking up a
// concurrent shard's job completing first
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
// ============================================================
// STEP 5-7: Verify via API
// ============================================================
// Step 5: User who satisfies policy but NOT in channel → should be AUTO-ADDED
const user1AfterSync = await verifyUserInChannel(adminClient, satisfyingUserNotInChannel.id, privateChannel.id);
expect(user1AfterSync).toBe(true); // AUTO-ADDED because auto-add is TRUE
// Step 6: User who satisfies policy and IS in channel → no change (stays in channel)
const user2AfterSync = await verifyUserInChannel(adminClient, satisfyingUserInChannel.id, privateChannel.id);
expect(user2AfterSync).toBe(true); // Stays in channel
// Step 7: User who does NOT satisfy policy and IS in channel → auto-removed
const user3AfterSync = await verifyUserInChannel(adminClient, nonSatisfyingUserInChannel.id, privateChannel.id);
expect(user3AfterSync).toBe(false); // AUTO-REMOVED
});
/**
* MM-63848: Creating a policy with a name that already exists should show an error
*/
test('MM-63848 Should show error when creating policy with duplicate name', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and policy creation
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Create the first policy
const policyName = `Duplicate Test ${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
// Navigate back and try to create another policy with the same name.
// Re-apply guards: a concurrent initSetup() may have reset EnableAttributeBasedAccessControl
// AND deleted custom profile attributes between the first createBasicPolicy call and now.
// Without the Department attribute the attributeSelectorMenuButton has no items and times out.
await navigateToABACPage(page);
await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Sales',
autoSync: false,
channels: [],
});
// Verify error message is shown
const errorMessage = page.locator('.EditPolicy__error');
await expect(errorMessage).toBeVisible({timeout: 5000});
const errorText = await errorMessage.textContent();
expect(errorText).toContain('A policy with this name already exists');
});
});

View File

@@ -0,0 +1,199 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
import {ensureUserAttributes, navigateToPermissionPoliciesPage} from '../support';
/**
* Permission Policies - Create Policy form UI and validation (MM-64508)
*
* Covers the create-form UI elements (name input, role dropdown, CEL editor mode
* toggle, info banner) and inline validation (empty name / expression / permissions).
* See permission_policies_create_save.spec.ts for the save/cancel flows that
* actually persist policies.
*/
test.describe('Permission Policies - Create Policy', () => {
test('MM-T5804 admin can open the create permission policy form', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Detail page heading and name input visible
await expect(
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
).toBeVisible();
await expect(systemConsolePage.page.getByPlaceholder('Add a unique policy name')).toBeVisible();
});
test('MM-T5805 create policy form shows evaluation order info banner', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Banner explains that permission policies override system permission schemes
await expect(
systemConsolePage.page.getByText('The permissions defined in this policy override the', {exact: false}),
).toBeVisible();
await expect(systemConsolePage.page.getByText('system permission schemes', {exact: false})).toBeVisible();
await expect(systemConsolePage.page.getByText('Permissions evaluation order', {exact: false})).toBeVisible();
});
test('MM-T5806 create policy form shows role dropdown defaulting to Members', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByText('Who this policy applies to')).toBeVisible();
await expect(
systemConsolePage.page.getByText('Select a role from the predefined list of system roles'),
).toBeVisible();
// * The dropdown button is visible and shows the default role
// (system_user). The label was shortened from "Members and
// system administrators" to just "Members" in the UX pass;
// the "system admins fall back when no admin-specific rule
// exists" semantics moved into the role's description copy.
// Use an exact-text matcher so a regression to the longer
// "Members and system administrators" label fails the test
// instead of silently passing the substring check.
const roleButton = systemConsolePage.page.locator('#pp-role-selector-btn');
await expect(roleButton).toBeVisible();
await expect(roleButton).toHaveText('Members');
});
test('MM-T5807 admin can change role selection to System administrators via dropdown', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// # Open the role dropdown and select System administrators
await systemConsolePage.page.locator('#pp-role-selector-btn').click();
await systemConsolePage.page.locator('#pp-role-option-system_admin').click();
// * Dropdown button now shows the selected role
await expect(systemConsolePage.page.locator('#pp-role-selector-btn')).toContainText('System administrators');
});
test('MM-T5808 admin can toggle between Simple and Advanced CEL editor modes', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
const switchToAdvanced = systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'});
await expect(switchToAdvanced).toBeVisible();
await switchToAdvanced.click();
// * Button label flips to Simple Mode
const switchToSimple = systemConsolePage.page.getByRole('button', {name: 'Switch to Simple Mode'});
await expect(switchToSimple).toBeVisible();
await switchToSimple.click();
await expect(switchToAdvanced).toBeVisible();
});
test('MM-T5809 Save is blocked when policy name is empty', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// # Type then clear the name to mark the form dirty, enabling the Save button
const nameInput = systemConsolePage.page.getByPlaceholder('Add a unique policy name');
await nameInput.fill('x');
await nameInput.clear();
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
await expect(systemConsolePage.page.getByText('Please add a name to the policy')).toBeVisible();
});
test('MM-T5810 Save is blocked when CEL expression is empty', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await systemConsolePage.page
.getByPlaceholder('Add a unique policy name')
.fill(`PP Expr Validate ${pw.random.id()}`);
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
await expect(systemConsolePage.page.getByText('Please add an expression to the policy')).toBeVisible();
});
test('MM-T5811 Save is blocked when no permission is selected', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await systemConsolePage.page
.getByPlaceholder('Add a unique policy name')
.fill(`PP Perm Validate ${pw.random.id()}`);
// # Enter a valid CEL expression but add no permissions
await systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'}).click();
const monacoContainer = systemConsolePage.page.locator('.monaco-editor').first();
await monacoContainer.waitFor({state: 'visible', timeout: 5000});
const editorLines = systemConsolePage.page.locator('.monaco-editor .view-lines').first();
await editorLines.click({force: true});
await systemConsolePage.page.waitForTimeout(300);
const isMac = process.platform === 'darwin';
await systemConsolePage.page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await systemConsolePage.page.waitForTimeout(100);
await systemConsolePage.page.keyboard.type('user.attributes.Department == "Engineering"', {delay: 10});
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
await expect(systemConsolePage.page.getByText('Please select at least one permission')).toBeVisible();
});
});

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPermissionPolicy,
deletePermissionPolicyByName,
navigateToPermissionPoliciesPage,
} from '../support';
/**
* Permission Policies - Create Policy save/cancel flows (MM-64508)
*
* Covers end-to-end creation flows that persist a policy (download-only,
* combined download+upload, guest role) plus the cancel path. Paired with
* permission_policies_create_form.spec.ts which covers form UI + validation.
*/
test.describe('Permission Policies - Create Policy', () => {
test('MM-T5812 admin can create a permission policy restricting file downloads', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
// Re-apply via API: a concurrent initSetup() on another shard may have
// disabled ABAC between the enableABAC UI call and the navigation to
// permission_policies, causing a redirect to the license page.
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
const policyName = `PP Download ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering"',
permissions: ['Download Files'],
});
// * List page shows the new policy with correct role and permissions
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await expect(policyRow).toBeVisible();
// Role label was shortened from "Members and system administrators"
// to just "Members" in the UX pass; the "system admins fall back
// when no admin-specific rule exists" semantics moved into the
// role's description copy.
await expect(policyRow.getByText('Members', {exact: true})).toBeVisible();
await expect(policyRow.getByText('Download Files')).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
test('MM-T5813 admin can create a permission policy with both Download and Upload permissions', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
const policyName = `PP Both Perms ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Legal"',
permissions: ['Download Files', 'Upload Files'],
});
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await expect(policyRow.getByText(/Download Files/)).toBeVisible();
await expect(policyRow.getByText(/Upload Files/)).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
test('MM-T5814 created policy appears in list with correct name, role, and permissions', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
const policyName = `PP List Check ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Legal"',
permissions: ['Download Files'],
role: 'system_guest',
});
// * Row shows name, Guest role, and Download Files permission
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await expect(policyRow).toBeVisible();
await expect(policyRow.getByText('Guest users')).toBeVisible();
await expect(policyRow.getByText('Download Files')).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
test('MM-T5815 admin can cancel policy creation and return to list', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
).toBeVisible();
// # Cancel navigates back to list without saving
await systemConsolePage.page.getByRole('link', {name: 'Cancel'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
});
});

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPermissionPolicy,
deletePermissionPolicyByName,
navigateToPermissionPoliciesPage,
} from '../support';
/**
* Permission Policies - System Console (MM-64508)
*
* Tests the Permission Policies page under System Attributes > Permission Policies.
* Requires Enterprise Advanced license and ABAC enabled.
*
* Sidebar items (Membership Policies, Permission Policies) are only rendered when
* ABAC is enabled — all tests call enableABAC() first.
*
* This file covers list-page UI and management (delete / search) of existing policies.
*/
test.describe('Permission Policies - List Page', () => {
test('MM-T5801 admin can navigate to Permission Policies page via sidebar', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Enable ABAC — sidebar items only appear when ABAC is on.
// enableABAC lands on /membership_policies so the sidebar is already expanded.
await enableABAC(systemConsolePage.page);
// # Click Permission Policies in the sidebar
await systemConsolePage.sidebar.systemAttributes.permissionPolicies.click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Correct URL and heading
await expect(systemConsolePage.page).toHaveURL(/permission_policies/);
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
// * List columns are present
const section = systemConsolePage.page.getByTestId('sysconsole_section_PermissionPolicies');
await expect(section.getByText('Name')).toBeVisible();
await expect(section.getByText('Role')).toBeVisible();
await expect(section.getByText('Permissions', {exact: true})).toBeVisible();
});
test('MM-T5802 Permission Policies list page has Add policy button and search input', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
await expect(systemConsolePage.page.getByPlaceholder('Search')).toBeVisible();
});
test('MM-T5803 Permission Policies list page subtitle describes file permission scope', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await expect(
systemConsolePage.page.getByText(
'Create policies to control file upload and download permissions based on user attributes',
{exact: false},
),
).toBeVisible();
});
});
test.describe('Permission Policies - Manage Existing Policies', () => {
test('MM-T5816 admin can delete a permission policy', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
const policyName = `PP Delete ${pw.random.id()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Delete"',
permissions: ['Download Files'],
});
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
// # Open the row's action menu and delete
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await policyRow.locator('button[id*="policy-menu"], button[aria-label*="menu" i], button').last().click();
const deleteOption = systemConsolePage.page.getByRole('menuitem', {name: /delete/i});
await deleteOption.click();
// # Deletion from the list fires immediately — no confirmation modal
await systemConsolePage.page.waitForLoadState('networkidle');
// * Policy no longer in list
await expect(systemConsolePage.page.getByText(policyName)).not.toBeVisible();
// # Safety net: API cleanup in case UI deletion failed
await deletePermissionPolicyByName(adminClient, policyName);
});
test('MM-T5817 admin can search for a permission policy by name', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
const policyName = `PP Search ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Search"',
permissions: ['Download Files'],
});
// # Search by the exact name
await systemConsolePage.page.getByPlaceholder('Search').fill(policyName);
await systemConsolePage.page.waitForLoadState('networkidle');
// * Only the matching policy is visible
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
});

View File

@@ -0,0 +1,235 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* E2E tests for ranked comparison operators in the Membership Policy editor.
*
* When the selected attribute is of type `rank`, the simple-mode operator
* dropdown replaces the standard set with the ordinal comparison operators
* (is exactly, is not, is at least, is greater than, is at most, is less than).
*/
import type {Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
import {deleteCustomProfileAttributes} from '../../../channels/custom_profile_attributes/helpers';
import {assertAccessControlAutocompleteContains} from '../support';
// Filter + real click so an off-screen ranked field is selected; click the
// policy name to dismiss a leftover MUI overlay (the filter swallows Escape).
async function selectAttributeInEditor(page: Page, attributeName: string) {
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
if (!(await attributeMenu.isVisible({timeout: 2000}).catch(() => false))) {
await attributeButton.click();
}
await expect(attributeMenu).toBeVisible();
const filterInput = attributeMenu.locator('.attribute-selector-search input');
await filterInput.waitFor({state: 'visible'});
await filterInput.fill(attributeName);
const option = attributeMenu.getByRole('menuitemradio', {name: attributeName, exact: true});
await expect(option).toBeVisible();
await option.click();
await expect(attributeButton).toContainText(attributeName);
if (await attributeMenu.isVisible().catch(() => false)) {
await page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName').click();
}
await expect(attributeMenu).toBeHidden();
}
test.describe('System Console - Membership Policy ranked operators', () => {
let adminClient: Client4;
let adminUser: UserProfile;
let field: UserPropertyField | undefined;
test.beforeEach(async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const clientInfo = await pw.getAdminClient();
adminClient = clientInfo.adminClient;
adminUser = clientInfo.adminUser!;
// # Create an admin-managed ranked attribute (admin-managed → usable in ABAC)
field = await adminClient.createCustomProfileAttributeField({
name: `clearance_${getRandomId()}`,
type: 'rank',
attrs: {
sort_order: 0,
managed: 'admin',
options: [
{name: 'Unclassified', rank: 1},
{name: 'Secret', rank: 2},
{name: 'TopSecret', rank: 3},
],
},
} as any);
// # Make sure ABAC stays enabled (a concurrent initSetup can reset config)
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as any);
await assertAccessControlAutocompleteContains(adminClient, [field.name]);
});
test.afterEach(async () => {
if (field) {
await deleteCustomProfileAttributes(adminClient, {
[field.id]: field,
ownedIds: new Set([field.id]),
} as any);
field = undefined;
}
});
/**
* @objective Selecting a ranked attribute in the policy editor surfaces the
* ordinal operators and removes the equality/string/list operators; the row
* defaults to "is at least".
*
* @precondition
* An admin-managed ranked attribute (Unclassified/Secret/TopSecret) exists.
*/
test('shows ranked comparison operators for a ranked attribute', {tag: '@abac'}, async ({pw}) => {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
// # ABAC is already enabled (see beforeEach) — go straight to Membership Policies
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Open the new-policy editor and name it
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(`Ranked Policy ${getRandomId()}`);
// # Add an attribute row (reload once if attributes haven't loaded yet)
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 10000});
if (await addAttributeButton.isDisabled()) {
await page.reload();
await page.waitForLoadState('networkidle');
await nameInput.fill(`Ranked Policy ${getRandomId()}`);
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
}
await addAttributeButton.click();
await selectAttributeInEditor(page, field!.name);
// * The row defaults to the canonical ranked operator, "is at least"
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
await expect(operatorButton).toContainText('is at least');
// # Open the operator dropdown
await operatorButton.click();
// * All six ranked operators are offered (menu items auto-wait for the open menu)
for (const label of ['is exactly', 'is not', 'is at least', 'is greater than', 'is at most', 'is less than']) {
await expect(page.getByRole('menuitemradio', {name: label, exact: true})).toBeVisible();
}
// * The standard (non-ranked) operators are not offered
for (const label of ['is', 'in', 'has any of', 'has all of', 'starts with', 'ends with', 'contains']) {
await expect(page.getByRole('menuitemradio', {name: label, exact: true})).toHaveCount(0);
}
});
/**
* @objective An "is at least <option>" rule built in the editor survives a
* save/reopen round-trip: the stored marker form rehydrates back to the
* operator form and the table editor re-renders the same operator and value.
*
* @precondition
* An admin-managed ranked attribute (Unclassified/Secret/TopSecret) exists.
*/
test('round-trips an "is at least" ranked rule through save and reopen', {tag: '@abac'}, async ({pw}) => {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
const policyName = `Ranked RT ${getRandomId()}`;
let policyId: string | null = null;
try {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Create a policy and add a ranked attribute row
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyName);
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 10000});
if (await addAttributeButton.isDisabled()) {
await page.reload();
await page.waitForLoadState('networkidle');
await nameInput.fill(policyName);
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
}
await addAttributeButton.click();
// # Select the ranked attribute by name
await selectAttributeInEditor(page, field!.name);
// * Defaults to "is at least"
await expect(page.locator('[data-testid="operatorSelectorMenuButton"]').first()).toContainText(
'is at least',
);
// # Pick the value "Secret"
await page.locator('[data-testid="valueSelectorMenuButton"]').first().click();
await page.getByRole('menuitemradio', {name: 'Secret', exact: true}).click();
await expect(page.locator('[data-testid="valueSelectorMenuButton"]').first()).toContainText('Secret');
// # Save the policy
await page.getByRole('button', {name: 'Save'}).last().click();
await page.waitForLoadState('networkidle');
// # Reopen the policy from the list
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).first();
await expect(policyRow).toBeVisible({timeout: 10000});
const rowId = await policyRow.getAttribute('id');
policyId = rowId?.replace('customDescription-', '') ?? null;
await policyRow.click();
await page.waitForLoadState('networkidle');
// Existing policies open in Advanced mode. The "Switch to Simple Mode"
// toggle is enabled only when isSimpleExpression accepts the stored
// expression — proving the marker form rehydrated to `>= "Secret"`
// (a non-rehydrated `_rank_ge(...)` marker would disable the toggle).
const toSimpleMode = page.getByRole('button', {name: 'Switch to Simple Mode'});
if (await toSimpleMode.isVisible({timeout: 5000}).catch(() => false)) {
await expect(toSimpleMode).toBeEnabled();
await toSimpleMode.click();
}
// * The table editor re-parses the rehydrated rule to the same operator
// and value (no rank integer surfaced in the value chip)
await expect(page.locator('[data-testid="operatorSelectorMenuButton"]').first()).toContainText(
'is at least',
);
const valueButton = page.locator('[data-testid="valueSelectorMenuButton"]').first();
await expect(valueButton).toContainText('Secret');
await expect(valueButton).not.toContainText('2');
} finally {
if (policyId) {
await adminClient.deleteAccessControlPolicy(policyId).catch(() => {});
}
}
});
});

View File

@@ -0,0 +1,226 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {createPrivateChannelForABAC, createBasicPolicy, enableUserManagedAttributes} from '../support';
/**
* ABAC Policy Management - Delete Policies
* Tests for deleting ABAC policies
*/
test.describe('ABAC Policy Management - Delete Policies', () => {
/**
* MM-T5793: Attribute-based access policy cannot be deleted if it is applied to any channels
*
* Step 1:
* 1. As system admin, go to ABAC page and click three-dot menu on a policy APPLIED to a channel
* 2. Observe Delete is DISABLED
* 3. Click three-dot menu on a policy NOT applied to any channels
* 4. Click Delete
*
* Expected:
* - Policy applied to a channel CANNOT be deleted (Delete is disabled)
* - Policy NOT applied to any channels CAN be deleted
*/
test('MM-T5793 Policy with channels cannot be deleted, policy without channels can be deleted', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
// Enable user-managed attributes
await enableUserManagedAttributes(adminClient);
// Set up a basic attribute field
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
await setupCustomProfileAttributeFields(adminClient, attributeFields);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// ===========================================
// Create two policies:
// 1. policyWithChannel - has a channel assigned
// 2. policyWithoutChannel - has NO channels assigned
// ===========================================
const uniqueId = pw.random.id();
const policyWithChannelName = `ABAC-WithChannel-${uniqueId}`;
const policyWithoutChannelName = `ABAC-NoChannel-${uniqueId}`;
// Create a channel for the first policy
const team = (await adminClient.getMyTeams())[0];
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// Create policy WITH channel
await createBasicPolicy(page, {
name: policyWithChannelName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
// Navigate back to ABAC page
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Create policy WITHOUT channel using UI (Advanced mode)
// We'll create the policy, save it without channels, then remove channels via UI
// Click Add policy
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.click();
await page.waitForLoadState('networkidle');
// Fill policy name
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyWithoutChannelName);
// Switch to Advanced mode to create minimal policy
const advancedModeButton = page.getByRole('button', {name: /advanced/i});
if (await advancedModeButton.isVisible({timeout: 2000})) {
await advancedModeButton.click();
await page.waitForTimeout(1000);
}
// Fill CEL expression in Monaco editor
const monacoContainer = page.locator('.monaco-editor').first();
await monacoContainer.waitFor({state: 'visible', timeout: 5000});
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.click({force: true});
await page.waitForTimeout(300);
// Type a simple expression
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.waitForTimeout(100);
await page.keyboard.type('user.attributes.Department == "Sales"', {delay: 10});
await page.waitForTimeout(1000);
// Save policy WITHOUT assigning any channels
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.click();
// The "Apply policy" modal should NOT appear since there are no channels
// The webapp will call handleSubmit() directly and navigate back automatically
// Wait for navigation to complete
await page.waitForURL('**/membership_policies', {timeout: 10000});
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1500);
// ===========================================
// STEP 1-2: Verify Delete is DISABLED for policy WITH channel
// ===========================================
// Clear any existing search and verify both policies exist
const searchInput = page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
await searchInput.clear();
await page.waitForTimeout(500);
// Verify both policies are visible
await page.locator('.policy-name, tr.clickable').count();
// Now search for the policy with channel
await searchInput.fill(policyWithChannelName);
await page.waitForTimeout(1000);
// Find and click the three-dot menu for the policy with channel
const policyWithChannelRow = page
.locator('tr.clickable, .DataGrid_row')
.filter({hasText: policyWithChannelName})
.first();
await policyWithChannelRow.waitFor({state: 'visible', timeout: 10000});
const menuButtonWithChannel = policyWithChannelRow
.locator('button[id*="policy-menu"], button[aria-label*="menu" i], .menu-button, button:has(svg)')
.first();
await menuButtonWithChannel.click();
await page.waitForTimeout(500);
// Check if Delete is disabled
const deleteMenuItemWithChannel = page.getByRole('menuitem', {name: /delete/i});
const isDeleteDisabled = await deleteMenuItemWithChannel.isDisabled();
// Close the menu
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
expect(isDeleteDisabled).toBe(true);
// ===========================================
// STEP 3-4: Verify Delete is ENABLED for policy WITHOUT channel and delete it
// ===========================================
// Clear search first to ensure we're seeing all policies
await searchInput.clear();
await page.waitForTimeout(500);
// Verify policy without channel exists in the list
const policyWithoutChannelExists = await page.locator('text=' + policyWithoutChannelName).count();
if (policyWithoutChannelExists === 0) {
// Try reloading if policy not visible
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}
// Now search for the policy without channel
await searchInput.fill(policyWithoutChannelName);
await page.waitForTimeout(1000);
// Find and click the three-dot menu for the policy without channel
const policyWithoutChannelRow = page
.locator('tr.clickable, .DataGrid_row')
.filter({hasText: policyWithoutChannelName})
.first();
await policyWithoutChannelRow.waitFor({state: 'visible', timeout: 10000});
const menuButtonWithoutChannel = policyWithoutChannelRow
.locator('button[id*="policy-menu"], button[aria-label*="menu" i], .menu-button, button:has(svg)')
.first();
await menuButtonWithoutChannel.click();
await page.waitForTimeout(500);
// Check if Delete is enabled
const deleteMenuItemWithoutChannel = page.getByRole('menuitem', {name: /delete/i});
const isDeleteEnabled = !(await deleteMenuItemWithoutChannel.isDisabled());
expect(isDeleteEnabled).toBe(true);
// Click Delete
await deleteMenuItemWithoutChannel.click();
await page.waitForTimeout(500);
// Handle confirmation modal if it appears
const confirmDeleteButton = page.getByRole('button', {name: /delete|confirm/i});
if (await confirmDeleteButton.isVisible({timeout: 2000})) {
await confirmDeleteButton.click();
await page.waitForTimeout(1000);
}
await page.waitForLoadState('networkidle');
// Verify the policy was deleted
await searchInput.clear();
await searchInput.fill(policyWithoutChannelName);
await page.waitForTimeout(1000);
const policyStillExists = await page
.locator('tr.clickable, .DataGrid_row')
.filter({hasText: policyWithoutChannelName})
.isVisible({timeout: 3000});
expect(policyStillExists).toBe(false);
});
});

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {createBasicPolicy, getPolicyIdByName} from '../support';
/**
* @objective E2E coverage for membership policy edit action navigation:
* - Clicking Edit in the policy row action menu navigates to the membership policy editor
*
* @reference MM-68958: Fix membership policy edit action navigation
*/
test.describe('ABAC Policy Management - Edit Action Navigation', () => {
/**
* MM-68958: Edit action in policy row menu navigates to membership policy editor
*
* Steps:
* 1. Enable ABAC and create a membership policy
* 2. Navigate to System Console > Membership Policies
* 3. Open a policy row's three-dot action menu
* 4. Click Edit
* 5. Verify the URL is /admin_console/system_attributes/membership_policies/edit_policy/<policy_id>
*/
test('MM-68958 Edit action navigates to membership policy editor', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Create a test channel for the policy
const channelName = `abac-edit-nav-test-${pw.random.id()}`;
const privateChannel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'P',
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Create a basic membership policy
const policyName = `Edit-Nav-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
// Get the policy ID from the backend
const policyId = await getPolicyIdByName(adminClient, policyName);
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
// Navigate to Membership Policies list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(1000);
// Search for the policy to ensure it's visible
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
// Find the policy row
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
// Open the three-dot action menu for the policy
const actionMenuButton = policyRowLocator
.locator('button[aria-label*="Actions" i], button:has(i.icon-dots-vertical)')
.first();
await actionMenuButton.waitFor({state: 'visible', timeout: 5000});
await actionMenuButton.click();
await page.waitForTimeout(500);
// Click the Edit menu item
const editMenuItem = page.locator(`[id*="policy-menu-edit-${policyId}"]`).first();
await editMenuItem.waitFor({state: 'visible', timeout: 5000});
// Click Edit and wait for navigation
await editMenuItem.click();
// Wait for the URL to change to the edit policy page
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
timeout: 10000,
});
// Verify we're on the edit policy page by checking the URL
const currentURL = page.url();
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
// Additional verification: Check that the policy editor is loaded
// The policy editor should have the policy name visible
const policyNameInput = page.locator('input[placeholder*="name" i], input[value*="Edit-Nav-Test"]').first();
await expect(policyNameInput).toBeVisible({timeout: 10000});
});
/**
* MM-68958: Row click also navigates to membership policy editor
*
* This test verifies that clicking the row (not just the Edit action) also navigates correctly.
* This behavior should have been working before, but we verify it still works after the fix.
*/
test('Row click navigates to membership policy editor', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Create a test channel for the policy
const channelName = `abac-row-click-test-${pw.random.id()}`;
const privateChannel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'P',
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Create a basic membership policy
const policyName = `Row-Click-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Sales',
autoSync: false,
channels: [privateChannel.display_name],
});
// Get the policy ID from the backend
const policyId = await getPolicyIdByName(adminClient, policyName);
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
// Navigate to Membership Policies list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(1000);
// Search for the policy to ensure it's visible
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
// Find the policy row
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
// Click the row (not the action menu)
await policyRowLocator.click();
// Wait for the URL to change to the edit policy page
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
timeout: 10000,
});
// Verify we're on the edit policy page
const currentURL = page.url();
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
});
});

View File

@@ -0,0 +1,939 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
verifyUserInChannel,
verifyUserNotInChannel,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
waitForPolicySyncJob,
getPolicyIdByName,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC Policy Management - Edit Policies
* Tests for editing existing ABAC policies
*/
test.describe('ABAC Policy Management - Edit Policies', () => {
/**
* MM-T5790: Editing value of existing attribute-based access policy applies access control as specified (without auto-add)
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is False
* 2. Edit an existing policy rule to a different value (same attribute and operator)
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies NEW policy but not in channel → NOT auto-added
*/
test('MM-T5790 Editing policy value applies access control without auto-add', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Enable user-managed attributes FIRST (same order as MM-T5783)
await enableUserManagedAttributes(adminClient);
// Set up the Department attribute field
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Create users with proper CPA attributes
// User A: Department=Engineering (will satisfy ORIGINAL policy)
// User B: Department=Sales (will satisfy EDITED policy)
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
// Create channel - use direct API call for more control
const channelName = `abac-edit-test-${pw.random.id()}`;
const privateChannel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'P',
});
// Admin user is automatically added as channel creator, but let's add the test users
// Note: addToChannel(userId, channelId) - user first, then channel
await adminClient.addToChannel(engineerUser.id, privateChannel.id);
// Verify we can access the channel
// const channelCheck = await adminClient.getChannel(privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Check membership BEFORE policy creation
// Using library helper verifyUserInChannel(client, userId, channelId)
await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
// Debug: Show user attributes BEFORE policy creation
try {
await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/users/${engineerUser.id}/custom_profile_attributes`,
{method: 'GET'},
);
} catch {
// Ignore errors
}
// ===========================================
// SETUP: Create policy with ORIGINAL value (Engineering), Auto-add OFF
// ===========================================
const policyName = `ABAC-Edit-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // Auto-add is OFF
channels: [privateChannel.display_name],
});
const policyId = await getPolicyIdByName(adminClient, policyName);
// Check membership AFTER policy creation (before explicit sync)
await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
// Wait for the automatic sync (triggered by createBasicPolicy's "Apply Policy") to complete
await page.waitForTimeout(3000); // Give time for sync job to run
// Check membership AFTER automatic sync
const engineerAfterSync = await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
const salesAfterSync = await verifyUserInChannel(adminClient, salesUser.id, privateChannel.id);
// Debug: Fetch user attributes to verify they're set
try {
await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/users/${engineerUser.id}/custom_profile_attributes`,
{method: 'GET'},
);
} catch {
// Ignore errors
}
expect(engineerAfterSync).toBe(true);
expect(salesAfterSync).toBe(false);
// ===========================================
// STEP 1-2: Edit policy to different value (Sales instead of Engineering)
// ===========================================
// Navigate to ABAC page and find the policy
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Search for and click the policy
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify Auto-add is OFF (check the header checkbox)
const autoAddCheckbox = page.locator('#auto-add-header-checkbox');
if (await autoAddCheckbox.isVisible({timeout: 3000})) {
const isChecked = await autoAddCheckbox.isChecked();
if (isChecked) {
// Uncheck it
await autoAddCheckbox.click();
await page.waitForTimeout(500);
}
} else {
// Checkbox not visible
}
// Edit the value: Change from "Engineering" to "Sales"
// Strategy 1: Try simple input field (for text attributes)
const simpleValueInput = page.locator('.values-editor__simple-input').first();
if (await simpleValueInput.isVisible({timeout: 3000})) {
// Clear and fill the new value
await simpleValueInput.click();
await simpleValueInput.fill('');
await simpleValueInput.fill('Sales');
// Press Tab or click elsewhere to confirm
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
} else {
// Strategy 2: Try value selector menu button
const valueButton = page.locator('[data-testid="valueSelectorMenuButton"]').first();
if (await valueButton.isVisible({timeout: 3000})) {
await valueButton.click();
await page.waitForTimeout(500);
// Look for input in the dropdown
const menuInput = page
.locator('#value-selector-menu input[type="text"], .value-selector-menu input')
.first();
if (await menuInput.isVisible({timeout: 2000})) {
await menuInput.fill('Sales');
await page.waitForTimeout(500);
// Click on the option or press Enter
const salesOption = page.locator('#value-selector-menu').getByText('Sales', {exact: true}).first();
if (await salesOption.isVisible({timeout: 2000})) {
await salesOption.click();
} else {
await page.keyboard.press('Enter');
}
await page.waitForTimeout(500);
}
} else {
// Strategy 3: Use Advanced mode to edit CEL expression directly
const advancedModeButton = page.getByRole('button', {name: /advanced|switch to advanced/i});
if (await advancedModeButton.isVisible({timeout: 3000})) {
await advancedModeButton.click();
await page.waitForTimeout(1000);
// Find Monaco editor - use view-lines with force click to bypass overlay
const monacoContainer = page.locator('.monaco-editor').first();
if (await monacoContainer.isVisible({timeout: 3000})) {
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.click({force: true});
await page.waitForTimeout(300);
// Platform-specific select all
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.waitForTimeout(100);
await page.keyboard.type('user.attributes.Department == "Sales"', {delay: 10});
await page.waitForTimeout(500);
}
}
}
}
// ===========================================
// STEP 3: Click Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Wait for sync to complete (race-safe: polls exact policy, not UI table)
await navigateToABACPage(page);
if (!policyId) {
throw new Error('Policy ID not found after creation');
}
await waitForPolicySyncJob(adminClient, policyId);
// ===========================================
// STEP 5 & 6: Verify channel membership after policy edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may flip membership.
await expect
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesUser should NOT be in channel (auto-add is off)',
})
.toBe(false);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be REMOVED (no longer satisfies policy)',
})
.toBe(false);
// ===========================================
// STEP 7: Admin can manually add satisfying user
// ===========================================
try {
// Note: addToChannel(userId, channelId) - user first, then channel
await adminClient.addToChannel(salesUser.id, privateChannel.id);
// Verify the user was actually added
const salesInChannelAfterManualAdd = await verifyUserInChannel(
adminClient,
salesUser.id,
privateChannel.id,
);
expect(salesInChannelAfterManualAdd).toBe(true);
} catch (error) {
throw new Error(`Step 7 FAILED: Admin should be able to manually add satisfying user. Error: ${error}`);
}
});
/**
* MM-T5791: Editing existing access policy to add another attribute applies access control as specified (with auto-add)
*
* Precondition: At least one policy in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit an existing policy rule to add another attribute/value
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies NEWLY EDITED policy but not in channel → auto-ADDED
* 6. User who doesn't satisfy NEWLY EDITED policy and is in channel → auto-REMOVED
*
* Expected:
* - User satisfying new multi-attribute policy IS auto-added
* - User not satisfying new policy IS auto-removed
*/
test('MM-T5791 Editing policy to add attribute with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Delete ALL existing custom attributes to start fresh
try {
const existingFields = await adminClient.getCustomProfileAttributeFields();
for (const field of existingFields) {
try {
await adminClient.deleteCustomProfileAttributeField(field.id);
} catch {
// Ignore deletion errors
}
}
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch {
// Ignore if no fields exist
}
// Enable user-managed attributes FIRST (same pattern as MM-T5783)
await enableUserManagedAttributes(adminClient);
// create attributes using direct API
const attributeFieldsMap: Record<string, any> = {};
const departmentField = await adminClient.createCustomProfileAttributeField({
name: 'Department',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 0},
} as any);
attributeFieldsMap[departmentField.id] = departmentField;
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies BOTH (after edit)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies ORIGINAL only, NOT the edited policy
// 3. salesUser: Dept=Sales → doesn't satisfy any policy
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
// Create channel and add engineerOfficeUser (satisfies original policy)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(engineerOfficeUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with ONE attribute (Department=Engineering)
// Auto-add ON so users are auto-added
// ===========================================
const policyName = `ABAC-AddAttr-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
const policyId = await getPolicyIdByName(adminClient, policyName);
// Wait for automatic sync to complete
await page.waitForTimeout(3000);
// Verify initial state after original policy sync
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to ADD another attribute (Office=Remote)
// New expression: Department=Engineering AND Office=Remote
// ===========================================
// Navigate back to ABAC list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(2000);
// Verify we're on the list page by checking for "Add policy" button
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Try to find the policy row first without search
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
const isPolicyVisible = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
// If not visible, use search
if (!isPolicyVisible) {
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.click();
await policySearchInput.fill(policyName);
await page.waitForTimeout(1500);
}
}
// Click policy to edit
await policyRowLocator.waitFor({state: 'visible', timeout: 15000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Check if "Add attribute" button is disabled (means attributes not loaded)
// If so, reload to fetch the Office attribute
const addAttributeButtonCheck = page.getByRole('button', {name: /add attribute/i});
if (await addAttributeButtonCheck.isVisible({timeout: 2000})) {
const isDisabled = await addAttributeButtonCheck.isDisabled();
if (isDisabled) {
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}
}
// Stay in Simple Mode and add a second attribute row
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 5000});
await addAttributeButton.click();
await page.waitForTimeout(1000);
// The attribute dropdown opens automatically after clicking "Add attribute"
// Wait for the menu to be visible and select "Office"
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
await attributeMenu.waitFor({state: 'visible', timeout: 5000});
const officeOption = attributeMenu.locator('li:has-text("Office")').first();
await officeOption.waitFor({state: 'visible', timeout: 5000});
await officeOption.click({force: true});
await page.waitForTimeout(500);
// Select operator "==" (is)
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').last();
await operatorButton.waitFor({state: 'visible', timeout: 5000});
await operatorButton.click({force: true});
await page.waitForTimeout(500);
const operatorOption = page.locator('[id^="operator-selector-menu"] li:has-text("is")').first();
await operatorOption.click({force: true});
await page.waitForTimeout(500);
// Fill value "Remote"
const valueInput = page.locator('.values-editor__simple-input').last();
await valueInput.waitFor({state: 'visible', timeout: 5000});
await valueInput.fill('Remote');
await page.waitForTimeout(500);
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Navigate to ABAC page and wait for auto-triggered sync job
await navigateToABACPage(page);
await page.waitForTimeout(2000);
// Wait for the auto-triggered sync job to complete (policy edit triggers sync automatically)
if (!policyId) {
throw new Error('Policy ID not found after creation');
}
await waitForPolicySyncJob(adminClient, policyId);
// Additional wait for membership changes to propagate
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should be in channel (satisfies BOTH attributes)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be REMOVED (only satisfies original, not new policy)',
})
.toBe(false);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesUser should not be in channel',
})
.toBe(false);
});
/**
* MM-T5792: Editing existing access policy to remove one of the rules applies access control as specified (with auto-add)
*
* Precondition: At least one policy with MULTIPLE rules in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit policy to REMOVE one of the rules (attribute/value)
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies newly edited (simpler) policy but not in channel → auto-ADDED
* 6. User who no longer satisfies newly edited policy and is in channel → auto-REMOVED
*
* Expected:
* - User satisfying new simpler policy IS auto-added
* - User not satisfying new policy IS auto-removed
*
* This is the OPPOSITE of MM-T5791:
* - MM-T5791: ADD rule → policy MORE restrictive
* - MM-T5792: REMOVE rule → policy LESS restrictive
*/
test('MM-T5792 Editing policy to remove attribute rule with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Delete ALL existing custom attributes to start fresh
try {
const existingFields = await adminClient.getCustomProfileAttributeFields();
for (const field of existingFields) {
try {
await adminClient.deleteCustomProfileAttributeField(field.id);
} catch {
// Ignore deletion errors
}
}
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch {
// Ignore if no fields exist
}
// Enable user-managed attributes FIRST (same pattern as MM-T5783)
await enableUserManagedAttributes(adminClient);
// create attributes using direct API
const attributeFieldsMap: Record<string, any> = {};
const departmentField = await adminClient.createCustomProfileAttributeField({
name: 'Department',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 0},
} as any);
attributeFieldsMap[departmentField.id] = departmentField;
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies ORIGINAL (both rules)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies EDITED policy (Dept only)
// 3. salesRemoteUser: Dept=Sales, Office=Remote → doesn't satisfy (wrong Dept)
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesRemoteUser.id);
// Create channel and add salesRemoteUser (does NOT satisfy any policy)
// This user will be REMOVED after we edit policy (to verify removal behavior)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesRemoteUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with TWO attributes
// Department=Engineering AND Office=Remote
// Auto-add ON
// ===========================================
const policyName = `ABAC-RemoveRule-${pw.random.id()}`;
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
// Use advanced mode for multi-attribute policy
await createAdvancedPolicy(page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering" && user.attributes.Office == "Remote"',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
const policyId = await getPolicyIdByName(adminClient, policyName);
// Wait for automatic sync to complete
await page.waitForTimeout(3000);
// Verify initial state after original policy sync
// Original policy: Department=Engineering AND Office=Remote
// - engineerRemoteUser: satisfies → should be auto-added
// - engineerOfficeUser: does NOT satisfy (HQ != Remote) → should NOT be in channel
// - salesRemoteUser: does NOT satisfy (Sales != Engineering) → should be auto-removed
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, salesRemoteUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to REMOVE Location rule
// New expression: Department=Engineering (only)
// This makes policy LESS restrictive
// ===========================================
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
// Navigate back to ABAC list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(2000);
// Verify we're on the list page by checking for "Add policy" button
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Try to find the policy row first without search
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
const isPolicyVisible = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
// If not visible, try with search
if (!isPolicyVisible) {
// Use a more specific selector for the search input in the policies table
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.click();
await policySearchInput.fill(policyName);
// DON'T press Enter - just wait for the search to filter
await page.waitForTimeout(1500);
}
}
// Wait for policy row to be visible
await policyRowLocator.waitFor({state: 'visible', timeout: 15000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify Auto-add is ON
const autoAddCheckbox = page.locator('#auto-add-header-checkbox');
if (await autoAddCheckbox.isVisible({timeout: 3000})) {
const isChecked = await autoAddCheckbox.isChecked();
if (!isChecked) {
await autoAddCheckbox.click();
await page.waitForTimeout(500);
}
}
// Check if Monaco editor is visible - if not, switch to Advanced mode
// Policy may open in Simple mode even if created in Advanced mode
let monacoContainer = page.locator('.monaco-editor').first();
const isMonacoVisible = await monacoContainer.isVisible({timeout: 2000}).catch(() => false);
if (!isMonacoVisible) {
const advancedModeButton = page.getByRole('button', {name: /advanced|switch to advanced/i});
if (await advancedModeButton.isVisible({timeout: 5000})) {
await advancedModeButton.click();
await page.waitForTimeout(2000); // Wait for Monaco to fully initialize
}
}
// Find Monaco editor and update expression to REMOVE Office rule
monacoContainer = page.locator('.monaco-editor').first();
await monacoContainer.waitFor({state: 'visible', timeout: 10000});
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.waitFor({state: 'visible', timeout: 5000});
await page.waitForTimeout(500);
// Click to focus the editor
await editorLines.click({force: true});
await page.waitForTimeout(300);
// Use platform-specific select all (Meta+a on Mac, Control+a on others)
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.waitForTimeout(200);
// Type the new CEL expression (REMOVING Office rule)
const newExpression = 'user.attributes.Department == "Engineering"';
await page.keyboard.type(newExpression, {delay: 10});
await page.waitForTimeout(1000);
// Wait for the "Valid" indicator to confirm the expression is valid
const validIndicator = page.locator('text=Valid').first();
try {
await validIndicator.waitFor({state: 'visible', timeout: 10000});
} catch {
// Ignore if Valid indicator doesn't appear
}
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Navigate to ABAC page and wait for sync job to complete (race-safe by policyId)
await navigateToABACPage(page);
if (!policyId) {
throw new Error('Policy ID not found after creation');
}
await waitForPolicySyncJob(adminClient, policyId);
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be AUTO-ADDED (satisfies simpler Dept-only policy)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should still be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesRemoteUser should NOT be in channel',
})
.toBe(false);
});
/**
* MM-63848: Renaming a policy to a name that already exists should show an error
*/
test('MM-63848 Should show error when renaming policy to an existing name', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Create two policies with different names
const policyName1 = `Edit Dup Test A ${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName1,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
await navigateToABACPage(page);
const privateChannel2 = await createPrivateChannelForABAC(adminClient, team.id);
const policyName2 = `Edit Dup Test B ${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName2,
attribute: 'Department',
operator: '==',
value: 'Sales',
autoSync: false,
channels: [privateChannel2.display_name],
});
// Navigate back and edit policy2's name to match policy1
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Search for the second policy
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName2);
await page.waitForTimeout(1000);
}
const policyRow = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName2}).first();
await policyRow.waitFor({state: 'visible', timeout: 10000});
await policyRow.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Change the name to match the first policy
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill('');
await nameInput.fill(policyName1);
// Save and expect failure
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.click();
await page.waitForTimeout(2000);
// Handle confirmation modal if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000}).catch(() => false)) {
await applyPolicyButton.click();
await page.waitForTimeout(2000);
}
// Verify error message is shown
const errorMessage = page.locator('.EditPolicy__error');
await expect(errorMessage).toBeVisible({timeout: 5000});
const errorText = await errorMessage.textContent();
expect(errorText).toContain('A policy with this name already exists');
});
});

View File

@@ -0,0 +1,549 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
getAdminClient,
test,
navigateToABACPage,
verifyUserInChannel,
verifyUserNotInChannel,
} from '@mattermost/playwright-lib';
import {
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
waitForLatestSyncJob,
getPolicyIdByName,
enableUserManagedAttributes,
} from '../support';
import {deleteFieldFromDB} from '../masking/masking_db_setup';
// Restore AccessControlSettings to the shared baseline expected by
// `specs/test_setup.ts` (ABAC enabled) after this file's tests complete, so
// later files on the same worker see the expected setup-state.
test.afterAll(async () => {
try {
const {adminClient} = await getAdminClient({skipLog: true});
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
} catch {
// Best-effort cleanup.
}
});
/**
* MM-T5791: Editing existing access policy to add another attribute applies access control as specified (with auto-add)
*
* Precondition: At least one policy in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit an existing policy rule to add another attribute/value
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies NEWLY EDITED policy but not in channel → auto-ADDED
* 6. User who doesn't satisfy NEWLY EDITED policy and is in channel → auto-REMOVED
*
* Expected:
* - User satisfying new multi-attribute policy IS auto-added
* - User not satisfying new policy IS auto-removed
*/
test('MM-T5791 Editing policy to add attribute with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure Office field exists with correct managed:admin permissions.
// Fields created by older server versions may lack permission_values, causing 403
// when setting attribute values — delete and recreate if permission_values is missing.
// Legacy fields with permission_field=null reject API deletion (403), so fall back to DB.
const existingFields = await adminClient.getCustomProfileAttributeFields();
const attributeFieldsMap: Record<string, any> = {};
for (const field of existingFields) {
attributeFieldsMap[field.id] = field;
}
const existingOffice = existingFields.find((f: any) => f.name === 'Office') as any;
if (!existingOffice || !(existingOffice as any).permission_values) {
if (existingOffice) {
try {
await adminClient.deleteCustomProfileAttributeField(existingOffice.id);
} catch {
await deleteFieldFromDB(existingOffice.id);
}
delete attributeFieldsMap[existingOffice.id];
}
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
}
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies BOTH (after edit)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies ORIGINAL only, NOT the edited policy
// 3. salesUser: Dept=Sales → doesn't satisfy any policy
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
// Create channel and add engineerOfficeUser (satisfies original policy)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(engineerOfficeUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with ONE attribute (Department=Engineering)
// Auto-add ON so users are auto-added
// ===========================================
const policyName = `ABAC-AddAttr-Test-${await pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
(await getPolicyIdByName(adminClient, policyName))!;
// Wait for automatic sync to complete
await page.waitForTimeout(500);
// Verify initial state after original policy sync
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to ADD another attribute (Office=Remote)
// New expression: Department=Engineering AND Office=Remote
// ===========================================
// Navigate back to ABAC list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(2000);
// Verify we're on the list page by checking for "Add policy" button
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Try to find the policy row first without search
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
let isPolicyVisible = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
// If not visible, use search
if (!isPolicyVisible) {
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
}
// Re-bind and poll — grid refresh under parallel load may be delayed.
await expect
.poll(() => policyRowLocator.isVisible(), {
timeout: 20_000,
message: `policy "${policyName}" should appear in grid after search`,
})
.toBe(true);
isPolicyVisible = true;
}
// Click policy to edit
await policyRowLocator.waitFor({state: 'visible', timeout: 15000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Check if "Add attribute" button is disabled (means attributes not loaded)
const addAttributeButtonCheck = page.getByRole('button', {name: /add attribute/i});
if (await addAttributeButtonCheck.isVisible({timeout: 2000})) {
const isDisabled = await addAttributeButtonCheck.isDisabled();
if (isDisabled) {
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}
}
// Stay in Simple Mode and add a second attribute row
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 5000});
await addAttributeButton.click();
await page.waitForTimeout(1000);
// The attribute dropdown opens automatically after clicking "Add attribute"
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
await expect
.poll(() => attributeMenu.isVisible(), {
timeout: 15_000,
message: 'attribute dropdown should appear',
})
.toBe(true);
const officeOption = attributeMenu.locator('li:has-text("Office")').first();
await expect
.poll(() => officeOption.isVisible(), {
timeout: 15_000,
message: 'Office option should be visible in attribute dropdown',
})
.toBe(true);
await officeOption.click({force: true});
await page.waitForTimeout(500);
// Select operator "==" (is)
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').last();
await operatorButton.waitFor({state: 'visible', timeout: 10_000});
await operatorButton.click({force: true});
await page.waitForTimeout(500);
const operatorOption = page.locator('[id^="operator-selector-menu"] li:has-text("is")').first();
await operatorOption.click({force: true});
await page.waitForTimeout(500);
// Fill value "Remote"
const valueInput = page.locator('.values-editor__simple-input').last();
await valueInput.waitFor({state: 'visible', timeout: 10_000});
await valueInput.fill('Remote');
await page.waitForTimeout(500);
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
// Intercept the sync-job POST triggered by "Apply policy" so we can poll the
// exact job ID instead of using the racy UI-table path.
const editSyncJobIdPromise = page
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 15_000})
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
.catch(() => null);
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Navigate to ABAC page and wait for auto-triggered sync job
await navigateToABACPage(page);
await page.waitForTimeout(500);
// Wait for the auto-triggered sync job to complete (policy edit triggers sync automatically)
const editSyncJobId = await editSyncJobIdPromise;
await waitForLatestSyncJob(page, 10, editSyncJobId);
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may briefly change
// membership after we read it, so we retry until stable.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should be in channel (satisfies BOTH attributes)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be REMOVED (does not satisfy new policy)',
})
.toBe(false);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesUser should not be in channel',
})
.toBe(false);
});
/**
* MM-T5792: Editing existing access policy to remove one of the rules applies access control as specified (with auto-add)
*
* Precondition: At least one policy with MULTIPLE rules in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit policy to REMOVE one of the rules (attribute/value)
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies newly edited (simpler) policy but not in channel → auto-ADDED
* 6. User who no longer satisfies newly edited policy and is in channel → auto-REMOVED
*
* This is the OPPOSITE of MM-T5791:
* - MM-T5791: ADD rule → policy MORE restrictive
* - MM-T5792: REMOVE rule → policy LESS restrictive
*/
test('MM-T5792 Editing policy to remove attribute rule with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure Office field exists with correct managed:admin permissions.
// Fields created by older server versions may lack permission_values, causing 403
// when setting attribute values — delete and recreate if permission_values is missing.
// Legacy fields with permission_field=null reject API deletion (403), so fall back to DB.
const existingFields = await adminClient.getCustomProfileAttributeFields();
const attributeFieldsMap: Record<string, any> = {};
for (const field of existingFields) {
attributeFieldsMap[field.id] = field;
}
const existingOffice = existingFields.find((f: any) => f.name === 'Office') as any;
if (!existingOffice || !(existingOffice as any).permission_values) {
if (existingOffice) {
try {
await adminClient.deleteCustomProfileAttributeField(existingOffice.id);
} catch {
await deleteFieldFromDB(existingOffice.id);
}
delete attributeFieldsMap[existingOffice.id];
}
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
}
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
await enableUserManagedAttributes(adminClient);
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies ORIGINAL (both rules)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies EDITED policy (Dept only)
// 3. salesRemoteUser: Dept=Sales, Office=Remote → doesn't satisfy (wrong Dept)
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesRemoteUser.id);
// Create channel and add salesRemoteUser (does NOT satisfy any policy)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesRemoteUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with TWO attributes
// Department=Engineering AND Office=Remote, Auto-add ON
// ===========================================
const policyName = `ABAC-RemoveRule-${await pw.random.id()}`;
await createAdvancedPolicy(page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering" && user.attributes.Office == "Remote"',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
(await getPolicyIdByName(adminClient, policyName))!;
// Wait for automatic sync to complete
await page.waitForTimeout(500);
// Verify initial state after original policy sync
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, salesRemoteUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to REMOVE Location rule
// New expression: Department=Engineering (only)
// ===========================================
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(500);
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Wait for the exact policy row to appear with retry — under parallel load the
// grid update from the server may lag behind the page load.
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
const found = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
if (!found) {
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
}
const policyRow = () => page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await expect
.poll(() => policyRow().isVisible(), {
timeout: 45_000,
intervals: [500, 1000, 2000, 3000],
message: `policy "${policyName}" should appear in grid after search`,
})
.toBe(true);
}
await page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first().click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify Auto-add is ON
const autoAddCheckbox = page.locator('#auto-add-header-checkbox');
if (await autoAddCheckbox.isVisible({timeout: 3000})) {
const isChecked = await autoAddCheckbox.isChecked();
if (!isChecked) {
await autoAddCheckbox.click();
await page.waitForTimeout(500);
}
}
// Remove the Office rule in Simple mode (table editor). Opening an Advanced-created policy
// can leave the UI in CEL mode; "Switch to Advanced Mode" stays disabled while attributes load.
const switchToSimpleButton = page.getByRole('button', {name: /switch to simple mode/i});
if (await switchToSimpleButton.isVisible({timeout: 5000}).catch(() => false)) {
await expect(switchToSimpleButton).toBeEnabled({timeout: 60_000});
await switchToSimpleButton.click();
await page.waitForTimeout(500);
}
const officeRowRemove = page
.locator('.table-editor__row')
.filter({hasText: 'Office'})
.getByRole('button', {name: 'Remove row'});
await expect(officeRowRemove).toBeVisible({timeout: 15_000});
await officeRowRemove.click();
await page.waitForTimeout(500);
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
// Intercept the sync-job POST triggered by "Apply policy" so we can poll the
// exact job ID instead of using the racy UI-table path.
const editSyncJobIdPromiseT5792 = page
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 15_000})
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
.catch(() => null);
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
await navigateToABACPage(page);
const editSyncJobIdT5792 = await editSyncJobIdPromiseT5792;
await waitForLatestSyncJob(page, 10, editSyncJobIdT5792);
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Re-apply guard: a concurrent initSetup() may have reset ABAC between the policy save
// and the sync job completing. Without ABAC enabled the sync job is a no-op.
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
// Poll under PW_WORKERS>=2: other shards' sync jobs may briefly change membership.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be AUTO-ADDED (satisfies simpler Dept-only policy)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should still be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesRemoteUser should NOT be in channel',
})
.toBe(false);
});

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {enableTeamMembershipPolicies} from '../teams/helpers';
import {createChannelTextField, createParentPolicyViaAPI, expectAssignTeamsDenied} from './helpers';
/**
* Authoring round-trip for resource.attributes.* over the real HTTP boundary
* (Playwright drives a live server with the enterprise engine, so SavePolicy /
* cel-check validation runs for real — the api4 Go tests mock the engine and
* cannot).
*
* One representative case per rule; the exhaustive save-validation matrix
* (multiselect reject, rank scale-match, permission-policy accept) lives in the
* enterprise engine unit tests.
*/
test.describe('ABAC resource.attributes - authoring', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('accepts a parent policy mixing user and resource attributes', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// Save succeeds and returns a policy id — the round-trip accepts a
// mixed user/resource expression on a parent policy.
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Accept Resource ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "us"`,
});
expect(policyId).toBeTruthy();
});
test('rejects has(resource.attributes.*) at check time', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// Absence is handled by deny-on-miss, so has() guards on resource
// attributes are rejected. cel/check surfaces the error to the editor.
// Assert the reason, not just that validation failed: a bare count also
// passes on an unrelated compile or engine error, and would have kept
// passing while the feature flag denied every resource reference.
const errors = await adminClient.checkAccessControlExpression(`has(resource.attributes.${attr})`);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].message).toContain('has() is not supported on resource attributes');
});
test('rejects assigning a resource parent to a team', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await enableTeamMembershipPolicies(adminClient);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Team Boundary ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "us"`,
});
// A team's resource is a team, which has no CPA attributes, so a parent
// that references resource.attributes.* must not be importable by a team.
await expectAssignTeamsDenied(adminClient, policyId, [team.id]);
});
});

View File

@@ -0,0 +1,295 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, type Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
/**
* Helpers for exercising resource.attributes.* (channel custom profile
* attributes) end to end. The subject of a policy stays the user; these helpers
* provision the *resource* side — channel-object-type CPA fields in the
* access_control group and per-channel values — plus API-driven policy authoring
* so a spec can exercise the resource side without also driving the policy
* editor UI.
*
* The server under test must run with MM_FEATUREFLAGS_RESOURCEATTRIBUTESINPOLICIES
* set: the feature is flag-gated, and a feature flag cannot be turned on through
* the config API (the config store restores flags on write), so no amount of
* patchConfig in a spec substitutes for the environment variable. Both CI paths
* set it — SERVER_ENV_BASELINE for the testcontainers stack the Playwright suite
* runs on, and e2e-tests/.ci/server.generate.sh for the docker-compose one — and
* every spec here guards with skipIfFeatureFlagNotSet so a server without it
* skips rather than failing on a policy save the server refuses.
*/
const PROPERTY_GROUP = 'access_control';
const CHANNEL_OBJECT_TYPE = 'channel';
const USER_OBJECT_TYPE = 'user';
const TEMPLATE_OBJECT_TYPE = 'template';
/**
* Create a channel-object-type text CPA field in the access_control group. The
* ABAC materialized view surfaces it as resource.attributes.<name>. Marked
* admin-managed so SavePolicy's name normalization accepts a reference to it
* (channel fields have no user-managed-attributes toggle). Returns the field id.
*/
export async function createChannelTextField(adminClient: Client4, name: string): Promise<string> {
const field = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
name,
type: 'text',
target_type: 'system',
target_id: '',
attrs: {managed: 'admin'},
} as Parameters<Client4['createPropertyField']>[2]);
return field.id;
}
/**
* Set a single channel's value for a channel CPA field. A refresh-before-read
* is not needed for the sync lane (the sync job reads a freshly refreshed
* matview), but note the matview is refreshed on a timer, so set values before
* triggering the sync job.
*/
export async function setChannelAttributeValue(
adminClient: Client4,
channelId: string,
fieldId: string,
value: string,
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, channelId, [{field_id: fieldId, value}]);
}
type ParentPolicyOptions = {
name: string;
expression: string;
version?: string;
};
/**
* Create a parent membership policy via the REST API. A parent is the reusable
* rule-carrier: assign channels to it (assignChannelsToPolicy) to put those
* channels under its rules. Returns the created policy id.
*/
export async function createParentPolicyViaAPI(adminClient: Client4, opts: ParentPolicyOptions): Promise<string> {
// The version sent here is advisory: CreateOrUpdateAccessControlPolicy
// overwrites it (v0.3, bumped to v0.4 only for permission-action rules), and
// no version gates resource.attributes.* — that's the
// ResourceAttributesInPolicies flag plus the "not a team policy" rule.
const policy = await adminClient.updateOrCreateAccessControlPolicy({
id: '',
name: opts.name,
type: 'parent',
version: opts.version ?? 'v0.3',
revision: 0,
active: true,
rules: [{expression: opts.expression, actions: ['membership']}],
});
return policy.id;
}
/**
* Assign channels to a parent policy (creates the per-channel child policy that
* imports the parent and flips enforcement on). Mirrors the System Console
* "Add channels" flow.
*/
export async function assignChannelsToPolicy(
adminClient: Client4,
policyId: string,
channelIds: string[],
): Promise<void> {
// The /assign endpoint returns 200 with an empty body but a JSON content
// type, which Client4.doFetch chokes on ("Unexpected end of JSON input").
// Use a raw request and check status, matching the pattern the rest of the
// ABAC e2e helpers use for these no-body endpoints.
const res = await fetch(`${adminClient.getBaseRoute()}/access_control_policies/${policyId}/assign`, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${adminClient.getToken()}`},
body: JSON.stringify({channel_ids: channelIds}),
});
if (!res.ok) {
throw new Error(`assign channels failed: ${res.status} ${await res.text()}`);
}
}
/**
* Trigger an access_control_sync job. Pass a policyId to target the channels
* governed by that policy; omit it for a global sweep. Returns the job id.
*/
export async function triggerSyncJob(adminClient: Client4, policyId?: string): Promise<string> {
const job = await adminClient.createAccessControlSyncJob(policyId ? {policy_id: policyId} : {});
return job.id;
}
export type MultiselectScale = {
templateId: string;
userFieldId: string;
userFieldName: string;
channelFieldId: string;
channelFieldName: string;
// Server-assigned option ids keyed by option name. Both the user and channel
// fields inherit these exact ids from the template (that's what "shared option
// scale" means), so hasAnyOf/hasAllOf compares the same id space on both sides.
optionIds: Record<string, string>;
};
/**
* Provision a shared-scale multiselect setup for a list-vs-list (hasAnyOf /
* hasAllOf) rule: a template multiselect field plus a user field and a channel
* field that both link to it via linked_field_id. All three live in the
* access_control group — a user CPA field in the custom_profile_attributes group
* cannot carry a linked_field_id (the server rejects it), and the scale-match
* validation requires the receiver (user) and argument (channel) fields to share
* a non-nil, equal linked_field_id. The linked user field still surfaces as
* user.attributes.<name> to the engine. Returns the field ids/names and the
* option ids (identical across all three fields).
*/
export async function createLinkedMultiselectScale(
adminClient: Client4,
baseName: string,
optionNames: string[],
): Promise<MultiselectScale> {
const template = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, {
name: `${baseName}_tmpl`,
type: 'multiselect',
target_type: 'system',
target_id: '',
attrs: {options: optionNames.map((name) => ({id: '', name, color: '#0000ff'}))},
} as Parameters<Client4['createPropertyField']>[2]);
const userFieldName = `${baseName}_user`;
const userField = await adminClient.createPropertyField(PROPERTY_GROUP, USER_OBJECT_TYPE, {
name: userFieldName,
type: 'multiselect',
target_type: 'system',
target_id: '',
linked_field_id: template.id,
} as Parameters<Client4['createPropertyField']>[2]);
const channelFieldName = `${baseName}_chan`;
const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
name: channelFieldName,
type: 'multiselect',
target_type: 'system',
target_id: '',
linked_field_id: template.id,
} as Parameters<Client4['createPropertyField']>[2]);
const optionIds: Record<string, string> = {};
for (const opt of (template.attrs?.options ?? []) as Array<{id: string; name: string}>) {
optionIds[opt.name] = opt.id;
}
return {
templateId: template.id,
userFieldId: userField.id,
userFieldName,
channelFieldId: channelField.id,
channelFieldName,
optionIds,
};
}
/**
* Set a user's multiselect value (list of option ids) for an access_control-group
* user field. Multiselect values are stored as option ids, not names.
*/
export async function setUserMultiselectValue(
adminClient: Client4,
userId: string,
fieldId: string,
optionIds: string[],
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, USER_OBJECT_TYPE, userId, [
{field_id: fieldId, value: optionIds},
]);
}
export async function setChannelMultiselectValue(
adminClient: Client4,
channelId: string,
fieldId: string,
optionIds: string[],
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, channelId, [
{field_id: fieldId, value: optionIds},
]);
}
/**
* Open an existing membership policy in the System Console editor by id. The
* parent-policy editor page carries the rule table and the Test-access-rule
* button, so UI specs provision the policy over the API and drive only the
* feature under test here.
*/
export async function openPolicyEditor(page: Page, policyId: string): Promise<void> {
await page.goto(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
await page.waitForLoadState('networkidle');
}
type ApiError = {status_code?: number; server_error_id?: string};
/**
* Run an API call that the server is expected to refuse, and assert it failed
* for the named reason. A bare `try { … } catch {}` proves only that something
* threw, so it passes just as happily on a 500, a transport fault, or a gating
* rejection that never reached the rule under test — which is how these specs
* once passed with the feature flag off.
*/
async function expectRejection(
call: () => Promise<unknown>,
expected: {statusCode: number; serverErrorId: string},
because: string,
): Promise<void> {
let error: ApiError | undefined;
try {
await call();
} catch (err) {
error = err as ApiError;
}
expect(error, `expected the server to refuse: ${because}`).toBeDefined();
expect(error?.status_code, because).toBe(expected.statusCode);
expect(error?.server_error_id, because).toBe(expected.serverErrorId);
}
/**
* Assert the runtime PDP refuses to add a user to a channel. The "user is not in
* the channel" check that usually follows can pass on its own, because the sync
* job already removed them — only this establishes that the add was refused.
*/
export async function expectAddToChannelDenied(adminClient: Client4, userId: string, channelId: string): Promise<void> {
await expectRejection(
() => adminClient.addToChannel(userId, channelId),
{statusCode: 403, serverErrorId: 'api.channel.add_user.to.channel.rejected'},
'the runtime PDP denies a non-matching user',
);
}
/**
* Assert assigning a parent policy to a team is refused. The team-boundary
* rejection carries its own error id, so pinning it rules out a permission,
* feature-gate or generic server error reaching this assertion instead.
*/
export async function expectAssignTeamsDenied(
adminClient: Client4,
policyId: string,
teamIds: string[],
): Promise<void> {
await expectRejection(
() => adminClient.assignTeamsToAccessControlPolicy(policyId, teamIds),
{statusCode: 400, serverErrorId: 'app.pap.save_policy.team_resource_attributes'},
'a team cannot import a parent that references resource.attributes.*',
);
}
/**
* Assert a policy save is refused because its expression still carries the
* masked-value sentinel. That rejection has its own error id, distinguishing
* it from other save failures rather than reporting only the general one.
*/
export async function expectMaskedTokenRejected(adminClient: Client4, opts: ParentPolicyOptions): Promise<void> {
await expectRejection(
() => createParentPolicyViaAPI(adminClient, opts),
{statusCode: 400, serverErrorId: 'app.pap.save_policy.masked_token_in_expression'},
'a masked sentinel cannot be resolved to a stored value',
);
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {createChannelTextField, expectMaskedTokenRejected} from './helpers';
/**
* Attribute-value masking on the resource.attributes.* write path.
*
* The masked-value sentinel ("--------", model.MaskingTokenValue) is a
* response-only placeholder the server substitutes for literals a caller can't
* see. It must never round-trip back into storage: saving a policy whose
* resource.attributes.* condition still carries the sentinel is rejected. This
* is the write-path half of the symmetric masking gate, asserted over HTTP.
*
* The read-path (caller-relative redaction of resource literals in GET /
* visual_ast / simulate) reuses the shared-template bridge and protected /
* shared_only fields, which require direct DB provisioning; that path is covered
* by the app-layer resolver test (TestAppMaskingResolver_ChannelFieldUsesUserHoldings),
* the CEL-walker/visual-AST masking tests, and the existing user-attribute
* masking Playwright suite whose mechanics the resource path shares.
*/
test.describe('ABAC resource.attributes - masking write path', {tag: ['@abac', '@abac_masking']}, () => {
test('rejects saving a resource.attributes condition carrying the masked sentinel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
// The sentinel rejection under test lives inside the server's
// AttributeValueMasking branch, so the flag has to be on. It cannot be
// turned on from here (the config store restores feature flags on
// write), so guard rather than try to set it.
await pw.skipIfFeatureFlagNotSet('AttributeValueMasking', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// The 8-dash sentinel is server-generated and never a real value, so a
// submitted expression containing it cannot be resolved to a stored
// value and is rejected.
await expectMaskedTokenRejected(adminClient, {
name: `Masked Sentinel ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "--------"`,
});
});
});

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
waitForPolicySyncJob,
} from '../support';
import {
assignChannelsToPolicy,
createChannelTextField,
createParentPolicyViaAPI,
expectAddToChannelDenied,
setChannelAttributeValue,
triggerSyncJob,
} from './helpers';
/**
* resource.attributes.* membership sync + enforcement (lane agreement).
*
* A policy that mixes the requesting user's attribute with the accessed
* channel's attribute (user.attributes.<a> == resource.attributes.<a>) must:
* - remove only the members whose value differs from the channel's (SQL sync lane),
* - let an admin add a matching user but block a non-matching one (runtime PDP lane),
* - remove everyone when the channel doesn't set the referenced attribute (deny-on-miss).
*
* Authoring/sync are driven through the REST API so the tests don't depend on
* the table-editor's RHS constraints.
*/
test.describe('ABAC resource.attributes - membership sync', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('mixed user/resource scalar policy syncs and enforces joins', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
// Same field name on both object types → user.attributes.<attr> compared
// to resource.attributes.<attr>.
const attr = `region${pw.random.id()}`;
const userAttribute: CustomProfileAttribute[] = [{name: attr, type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, userAttribute);
const channelFieldId = await createChannelTextField(adminClient, attr);
const matchingUserNotInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
const matchingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
const nonMatchingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'eu'},
]);
for (const u of [matchingUserNotInChannel, matchingUserInChannel, nonMatchingUserInChannel]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelAttributeValue(adminClient, channel.id, channelFieldId, 'us');
await adminClient.addToChannel(matchingUserInChannel.id, channel.id);
await adminClient.addToChannel(nonMatchingUserInChannel.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Resource Region ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane on a private channel with an active policy:
// - the non-matching (eu) member is removed,
// - the matching (us) member stays,
// - the matching (us) user who was only in the team is auto-added —
// an active private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchingUserInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatchingUserInChannel.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchingUserNotInChannel.id, channel.id)).toBe(true);
// Runtime lane agrees with the sync result: re-adding the matching user
// succeeds, while adding the non-matching user is blocked.
await adminClient.addToChannel(matchingUserNotInChannel.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchingUserNotInChannel.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatchingUserInChannel.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatchingUserInChannel.id, channel.id)).toBe(false);
});
test('deny-on-miss removes all members when the channel attribute is absent', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: attr, type: 'text', value: ''},
]);
// Channel field exists so the reference resolves at save time, but the
// channel below never sets a value → the referenced field is missing for
// that channel → deny-on-miss.
await createChannelTextField(adminClient, attr);
const member = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
await adminClient.addToTeam(team.id, member.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(member.id, channel.id);
expect(await verifyUserInChannel(adminClient, member.id, channel.id)).toBe(true);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Resource DenyOnMiss ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// The channel is missing the referenced attribute, so the whole policy
// denies and every governed member is removed — fail-secure.
expect(await verifyUserInChannel(adminClient, member.id, channel.id)).toBe(false);
});
});

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import {
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
waitForPolicySyncJob,
} from '../support';
import {
assignChannelsToPolicy,
createLinkedMultiselectScale,
createParentPolicyViaAPI,
expectAddToChannelDenied,
setChannelMultiselectValue,
setUserMultiselectValue,
triggerSyncJob,
} from './helpers';
/**
* List-vs-list (multiselect) resource targets end to end: a rule comparing a
* multiselect user attribute against a multiselect channel attribute with
* hasAnyOf / hasAllOf, authored here over the real HTTP boundary. Both fields
* link to a shared template (equal linked_field_id) so they share one option-id
* scale — the only shape the save-time validation accepts.
*
* Each test asserts the two evaluation lanes agree on the same fixtures:
* - SQL sync lane: a non-matching member is removed, a matching member stays,
* and a matching team-only user is auto-added to the assigned private channel.
* - Runtime PDP lane: re-adding a matching user succeeds; adding a non-matching
* user is blocked.
*
* hasAnyOf = the lists intersect. hasAllOf = the channel's list is a subset of
* the user's (the user holds every value the channel requires).
*/
test.describe('ABAC resource.attributes - multiselect targets', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('has any of syncs and enforces on list intersection', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const scale = await createLinkedMultiselectScale(adminClient, `region${pw.random.id()}`, [
'alpha',
'beta',
'gamma',
]);
const {alpha, beta, gamma} = scale.optionIds;
// Channel requires [alpha, beta]. has-any-of matches a user sharing >=1.
const matchInChannel = await createUserForABAC(adminClient, {}, []); // [beta] -> shares beta
const matchTeamOnly = await createUserForABAC(adminClient, {}, []); // [alpha] -> shares alpha
const nonMatch = await createUserForABAC(adminClient, {}, []); // [gamma] -> disjoint
await setUserMultiselectValue(adminClient, matchInChannel.id, scale.userFieldId, [beta]);
await setUserMultiselectValue(adminClient, matchTeamOnly.id, scale.userFieldId, [alpha]);
await setUserMultiselectValue(adminClient, nonMatch.id, scale.userFieldId, [gamma]);
for (const u of [matchInChannel, matchTeamOnly, nonMatch]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelMultiselectValue(adminClient, channel.id, scale.channelFieldId, [alpha, beta]);
await adminClient.addToChannel(matchInChannel.id, channel.id);
await adminClient.addToChannel(nonMatch.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `HasAnyOf ${pw.random.id()}`,
expression: `user.attributes.${scale.userFieldName}.hasAnyOf(resource.attributes.${scale.channelFieldName})`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane: the disjoint member is removed, the intersecting member
// stays, and the intersecting team-only user is auto-added — an active
// private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
// Runtime PDP lane agrees with the sync verdict: re-adding the
// intersecting user succeeds, while the disjoint one is blocked.
await adminClient.addToChannel(matchTeamOnly.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatch.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
});
test('has all of syncs and enforces on channel-list subset', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const scale = await createLinkedMultiselectScale(adminClient, `clr${pw.random.id()}`, [
'alpha',
'beta',
'gamma',
]);
const {alpha, beta, gamma} = scale.optionIds;
// Channel requires [alpha, beta]. has-all-of matches only a user holding
// BOTH — a user with just alpha does not (missing beta).
const matchInChannel = await createUserForABAC(adminClient, {}, []); // [alpha, beta, gamma] superset
const matchTeamOnly = await createUserForABAC(adminClient, {}, []); // [alpha, beta] exact
const nonMatch = await createUserForABAC(adminClient, {}, []); // [alpha] missing beta
await setUserMultiselectValue(adminClient, matchInChannel.id, scale.userFieldId, [alpha, beta, gamma]);
await setUserMultiselectValue(adminClient, matchTeamOnly.id, scale.userFieldId, [alpha, beta]);
await setUserMultiselectValue(adminClient, nonMatch.id, scale.userFieldId, [alpha]);
for (const u of [matchInChannel, matchTeamOnly, nonMatch]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelMultiselectValue(adminClient, channel.id, scale.channelFieldId, [alpha, beta]);
await adminClient.addToChannel(matchInChannel.id, channel.id);
await adminClient.addToChannel(nonMatch.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `HasAllOf ${pw.random.id()}`,
expression: `user.attributes.${scale.userFieldName}.hasAllOf(resource.attributes.${scale.channelFieldName})`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane: the superset member stays, the member missing a required
// value is removed, and the exact-match team-only user is auto-added — an
// active private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
// Runtime PDP lane agrees: re-adding the exact-match user succeeds, the
// subset-missing user is blocked.
await adminClient.addToChannel(matchTeamOnly.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatch.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
});
});

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
assertAccessControlAutocompleteContains,
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
} from '../support';
import {createChannelTextField, createParentPolicyViaAPI, openPolicyEditor, setChannelAttributeValue} from './helpers';
/**
* Test-matching-users channel picker end to end.
*
* A parent policy that references resource.attributes.* has no channel scope of
* its own, so "Test access rule" cannot resolve the rule until the admin picks a
* concrete channel. The shared test modal therefore opens a channel-picker step
* first; the picked channel's attribute values are threaded into the matching-users
* query. This asserts that flow over the real UI + HTTP: the picker finds a
* channel by name, choosing it resolves the rule against that channel (a matching
* user appears, a non-matching one does not), and the back arrow returns to the
* picker.
*
* The matching-users query reads the attribute materialized view, refreshed on a
* throttled (~30s) cadence, so the first read after setting values may lag — the
* matching assertion re-searches until the view catches up.
*/
test.describe('ABAC resource.attributes - test picker', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('picker resolves a resource rule against the chosen channel', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
// Same field name on both object types so user.attributes.<attr> compares
// to resource.attributes.<attr>.
const attr = `region${pw.random.id()}`;
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, [{name: attr, type: 'text', value: ''}]);
const channelFieldId = await createChannelTextField(adminClient, attr);
const userUS = await createUserForABAC(adminClient, fieldsMap, [{name: attr, type: 'text', value: 'us'}]);
const userEU = await createUserForABAC(adminClient, fieldsMap, [{name: attr, type: 'text', value: 'eu'}]);
await adminClient.addToTeam(team.id, userUS.id);
await adminClient.addToTeam(team.id, userEU.id);
const channelUS = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelAttributeValue(adminClient, channelUS.id, channelFieldId, 'us');
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Picker ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
// Fail fast if the editor won't see the attribute (keeps the Test button disabled).
await assertAccessControlAutocompleteContains(adminClient, [attr]);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await openPolicyEditor(page, policyId);
const testButton = page.getByRole('button', {name: /test access rule/i});
await expect(testButton).toBeVisible({timeout: 10000});
await expect(testButton).toBeEnabled({timeout: 15000});
await testButton.click();
const modal = page.locator('.TestResultsModal');
// Picker step: no channel scope on the parent, so the picker precedes the
// members list.
await expect(modal.getByText('Select a channel to test against')).toBeVisible({timeout: 10000});
const channelSearch = modal.locator('.TestChannelPicker__search-input');
await channelSearch.fill(channelUS.display_name);
const channelRow = modal.locator('.TestChannelPicker__row', {hasText: channelUS.display_name});
await expect(channelRow).toBeVisible({timeout: 10000});
await channelRow.click();
// Members step: the rule now resolves against channelUS (region == "us").
await expect(modal.getByText('Access Rule Test Results')).toBeVisible({timeout: 10000});
await expect(modal.locator('.TestResultsModal__back')).toBeVisible();
const memberSearch = modal.locator('input[placeholder*="Search" i]').first();
await expect(async () => {
await memberSearch.fill(userUS.username);
await expect(modal.locator('.more-modal__name', {hasText: userUS.username})).toBeVisible({timeout: 3000});
}).toPass({timeout: 60000, intervals: [3000]});
// The non-matching user (region == "eu") is not admitted against this channel.
// Wait for the search round-trip before asserting absence: the previous
// row is cleared the moment the term changes, so toHaveCount(0) would
// pass on that intermediate empty render even if userEU were admitted.
const euSearch = page.waitForResponse(
(r) => r.url().includes('/access_control_policies/cel/test') && r.request().method() === 'POST',
{timeout: 15000},
);
await memberSearch.fill(userEU.username);
await euSearch;
await expect(modal.locator('.more-modal__name', {hasText: userEU.username})).toHaveCount(0);
// The back arrow returns to the picker (only present because it preceded).
await modal.locator('.TestResultsModal__back').click();
await expect(modal.getByText('Select a channel to test against')).toBeVisible({timeout: 10000});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Add Members to Team modal (System Console) blocks non-qualifying candidates in strict mode
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies} from './helpers';
test.describe('ABAC - Add members to team (System Console)', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-68846-T7 PRIVATE governed team blocks non-qualifying candidates inline', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create private team with Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 (Engineering) and mkt1 (Marketing) — not added to team yet
const createUser = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
first_name: dept,
last_name: label,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// # Log in as system admin and navigate to the per-team console page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(`/admin_console/user_management/teams/${team.id}`);
// # Click "Add Members" button
await page.locator('#addTeamMembers').click();
const addModal = page.locator('#addUsersToTeamModal');
await expect(addModal).toBeVisible({timeout: 10000});
// MultiSelect uses React-Select which renders the input as a combobox role, not
// a plain <input placeholder="...">. Use getByRole to match the accessible name.
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
await expect(searchInput).toBeVisible({timeout: 10000});
// # Search for eng1 — qualifying candidate
await searchInput.pressSequentially(eng1.username.slice(0, 8));
const eng1Row = addModal.locator('.more-modal__row', {hasText: eng1.username});
await expect(eng1Row).toBeVisible({timeout: 10000});
// * eng1's row is NOT disabled
await expect(eng1Row).not.toHaveClass(/more-modal__row--disabled/);
await expect(eng1Row.locator('.more-modal__error')).not.toBeVisible();
// # Search for mkt1 — non-qualifying candidate
await searchInput.clear();
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
await expect(mkt1Row).toBeVisible({timeout: 10000});
// * mkt1's row is disabled with the blocking message
await expect(mkt1Row).toHaveClass(/more-modal__row--disabled/);
await expect(mkt1Row.locator('.more-modal__error')).toHaveText('Does not meet membership requirements');
});
test('MM-68846-T8 PUBLIC governed team does NOT block candidates (advisory)', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create public team with Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 and mkt1
const createUser = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
first_name: dept,
last_name: label,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Advisory policy (public team)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// # Log in as system admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(`/admin_console/user_management/teams/${team.id}`);
// # Click "Add Members" button
await page.locator('#addTeamMembers').click();
const addModal = page.locator('#addUsersToTeamModal');
await expect(addModal).toBeVisible({timeout: 10000});
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
await expect(searchInput).toBeVisible({timeout: 10000});
// # Search for mkt1 — non-qualifying but advisory = no block
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
await expect(mkt1Row).toBeVisible({timeout: 10000});
// * Row is NOT disabled
await expect(mkt1Row).not.toHaveClass(/more-modal__row--disabled/);
await expect(mkt1Row.locator('.more-modal__error')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import {expect, test} from '@mattermost/playwright-lib';
import {
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValuesForUser,
} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {createUserForABAC, ensureUserAttributes} from '../support';
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
/**
* ABAC — Team directory hiding
*
* Enforcement is mode-dependent. A private (non-open-invite) governed team is
* strict: it is surfaced into the directory only for non-members who satisfy the
* policy and omitted for everyone else. A public (open-invite) governed team is
* advisory: it stays visible to all. Asserted at the API layer (each user's own
* client) so it proves server-side filtering, not just DOM hiding — exercising the
* full api4 → app filter → store-listing chain, including the governed-team
* listing widening that surfaces private governed teams to qualifying users.
*/
test.describe('ABAC - Team directory hiding', {tag: ['@abac', '@team_membership']}, () => {
// Each test cleans up the resources it creates, even on failure. initSetup's
// own team/users are owned by the framework and left alone.
let cleanupClient: Client4 | undefined;
const createdTeamIds: string[] = [];
const createdUserIds: string[] = [];
const createdPolicyIds: string[] = [];
test.afterEach(async () => {
const client = cleanupClient;
cleanupClient = undefined;
const policyIds = createdPolicyIds.splice(0);
const teamIds = createdTeamIds.splice(0);
const userIds = createdUserIds.splice(0);
if (!client) {
return;
}
const base = client.getBaseRoute();
const headers = {Authorization: `Bearer ${client.getToken()}`};
for (const id of policyIds) {
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
}
for (const id of teamIds) {
await client.deleteTeam(id).catch(() => {});
}
// Hard user deletion requires server config; deactivating is the API-safe cleanup.
for (const id of userIds) {
await client.updateUserActive(id, false).catch(() => {});
}
});
test('MM-68846-T1 - omits a policy-governed team from the directory for non-qualifying users', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Two non-members: one qualifies (Engineering), one does not (Sales).
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(qualUser.id, nonQualUser.id);
// A private (non-open-invite) governed team is strict: ABAC surfaces it into
// the directory only for users who qualify and hides it from everyone else.
// The policy never mutates allow_open_invite.
await adminClient.patchTeam({id: team.id, allow_open_invite: false} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Directory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [team.id]);
// Control: a private team with NO policy. The listing widening must surface
// only governed teams, so this one stays invisible to both users regardless
// of qualification — guarding against the OR clause over-widening.
const ungoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Ungoverned ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(ungoverned.id);
// ABAC evaluates against a materialized view that refreshes on an interval;
// wait until the qualifying user is visible to the evaluator before asserting.
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
// The child policy row is written to the master DB; the directory query
// reads from a read replica. Poll policy_enforced until the replica has
// caught up so the EXISTS subquery in GetAllPage widens correctly.
await expect
.poll(async () => (await adminClient.getTeam(team.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'team should show policy_enforced=true before asserting directory visibility',
})
.toBe(true);
// API layer: proves the server filters the listing, not just the DOM.
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
// Paginate to collect all visible teams — busy CI environments accumulate
// many public teams across test runs, so a single page-0 fetch misses teams
// that land beyond per_page=200. Use batch.length === 0 (not < per_page) as
// the stop condition: the server applies FilterNonQualifyingTeamsForUser
// after fetching a full DB page, so a batch can legitimately come back
// smaller than per_page while more pages still exist in the DB.
const allTeamIds = async (client: Client4): Promise<string[]> => {
const ids: string[] = [];
let page = 0;
while (true) {
const batch = (await client.getTeams(page, 200)) as Team[];
if (batch.length === 0) {
break;
}
ids.push(...batch.map((t) => t.id));
page++;
}
return ids;
};
const qualIds = await allTeamIds(qualClient);
const nonQualIds = await allTeamIds(nonQualClient);
expect(qualIds).toContain(team.id);
expect(nonQualIds).not.toContain(team.id);
// The ungoverned private team is never surfaced, qualified or not.
expect(qualIds).not.toContain(ungoverned.id);
expect(nonQualIds).not.toContain(ungoverned.id);
// The boundary is asserted at the API layer. The /select_team directory page
// additionally filters joinable teams to open-invite teams client-side, so it
// never renders a private governed team regardless of the server decision —
// surfacing those in the directory UI is separate webapp work.
});
test('MM-68846-T2 - hides a governed team from a non-qualifying system admin in the directory while the management listing stays complete', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Give the admin a present-but-non-matching Department so the policy
// deterministically denies them. A missing attribute is not the same as a
// wrong one — the expression must evaluate to false, not error on an absent
// key — so the admin gets "Sales" against an "Engineering" policy.
await setupCustomProfileAttributeValuesForUser(
adminClient,
[{name: 'Department', value: 'Sales', type: 'text'}],
attributeFieldsMap,
adminUser.id,
);
// A private (non-open-invite), policy-governed team the admin is NOT a member
// of: create it as admin (the creator is auto-joined), then remove the admin
// so the directory treats them as a non-member non-qualifying browser. Private
// → strict, so for_directory hiding applies to the admin too.
const governed = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Governed ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(governed.id);
await adminClient.removeFromTeam(governed.id, adminUser.id);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Admin Directory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [governed.id]);
const base = adminClient.getBaseRoute();
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
const listDirectoryTeamIds = async (forDirectory: boolean): Promise<string[]> => {
const url = `${base}/teams?page=0&per_page=200${forDirectory ? '&for_directory=true' : ''}`;
const resp = await fetch(url, {headers});
const teams = (await resp.json()) as Team[];
return teams.map((t) => t.id);
};
// The assignment becomes visible through a materialized attribute view and a
// read replica, so poll the directory listing until the governed team drops
// out for the admin (for_directory) rather than asserting on the first read.
// A transient fetch error counts as "not ready yet" so the poll keeps going.
await expect
.poll(
async () => {
try {
return (await listDirectoryTeamIds(true)).includes(governed.id);
} catch {
return true;
}
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'governed team should be hidden from the admin directory listing',
},
)
.toBe(false);
// Without for_directory the admin stays exempt, so the System Console listing
// is still complete.
expect(await listDirectoryTeamIds(false)).toContain(governed.id);
// UI: with the backend confirmed above, the team-selection directory hides it
// for the admin too.
const adminSession = await pw.testBrowser.login(adminUser);
await adminSession.page.goto('/select_team');
await adminSession.page.waitForLoadState('networkidle');
await expect(adminSession.page.getByText(governed.display_name, {exact: false})).toHaveCount(0);
});
test('MM-68846-T3 - keeps a public governed team visible to a non-qualifying user (advisory mode)', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// A user who does NOT satisfy the policy (Sales against an Engineering rule).
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(nonQualUser.id);
// A public (open-invite) governed team: the policy is advisory, so it stays
// in the directory for everyone regardless of qualification — no hiding.
// open-invite is set via patch (the create payload does not persist it).
const publicGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Public Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Advisory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
// Confirm enforcement is actually live before asserting advisory behavior —
// otherwise "visible" is indistinguishable from a plain ungoverned public team
// and a regression to strict-on-public would go undetected.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// Advisory: despite being governed, the public team stays visible to the
// non-qualifying user — the policy never hides it.
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
expect(nonQualTeams.map((t) => t.id)).toContain(publicGoverned.id);
});
test('MM-68846-T4 - join gate forks on privacy mode: advisory public admits, strict private denies', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Present-but-wrong attribute (Sales against an Engineering rule) so the PDP
// evaluates the user to false rather than erroring on a missing key.
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(nonQualUser.id);
// The admin drives the readiness poll below; give them the same present-but-
// wrong attribute so the private team deterministically drops from their
// for_directory listing once enforcement is live.
await setupCustomProfileAttributeValuesForUser(
adminClient,
[{name: 'Department', value: 'Sales', type: 'text'}],
attributeFieldsMap,
adminUser.id,
);
const expression = 'user.attributes.Department == "Engineering"';
const newGovernedTeam = async (open: boolean) => {
const t = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(t.id);
if (open) {
// open-invite is set via patch (the create payload does not persist it).
await adminClient.patchTeam({id: t.id, allow_open_invite: true} as any);
}
// The creator is auto-joined, and members always retain directory
// visibility — remove the admin so they read as a non-member browser.
await adminClient.removeFromTeam(t.id, adminUser.id);
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Join Gate ${open ? 'Public' : 'Private'} ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [t.id]);
return t;
};
const publicGoverned = await newGovernedTeam(true);
const privateGoverned = await newGovernedTeam(false);
// The private team's enforcement reaches the join gate through a materialized
// view and a read replica. Poll the admin's for_directory listing until the
// team drops out: the admin always has it in-candidate (list_private_teams),
// so it dropping out positively confirms the policy is live AND the PDP
// denies — exactly the state the join gate evaluates. Polling the
// non-qualifying user's listing would be ambiguous (absent both before the
// policy propagates and after it is hidden).
const base = adminClient.getBaseRoute();
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
await expect
.poll(
async () => {
try {
const resp = await fetch(`${base}/teams?page=0&per_page=200&for_directory=true`, {headers});
return ((await resp.json()) as Team[]).map((t) => t.id).includes(privateGoverned.id);
} catch {
return true;
}
},
{timeout: 60_000, intervals: [1000, 2000, 5000, 5000, 5000]},
)
.toBe(false);
// Confirm the public team is genuinely governed before asserting advisory
// admission — otherwise a "successful join" would just be a plain public team
// and a regression to strict-on-public would slip through.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// Advisory: the gate is skipped on a governed public team, so the
// non-qualifying user is admitted.
const member = await adminClient.addToTeam(publicGoverned.id, nonQualUser.id);
expect(member.user_id).toBe(nonQualUser.id);
// Strict: the same user is denied on the private team — directory visibility
// never translates into join access, and no role bypasses the gate.
let denied = false;
try {
await adminClient.addToTeam(privateGoverned.id, nonQualUser.id);
} catch {
denied = true;
}
expect(denied).toBe(true);
});
});

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import {expect, test} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {createUserForABAC, ensureUserAttributes} from '../support';
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
/**
* ABAC — Team directory "Recommended" tag
*
* A public (open-invite) governed team is advisory: it stays visible to everyone,
* and a non-member who satisfies the policy is surfaced a transient, per-viewer
* "recommended" hint. The flag is fail-secure (a non-qualifying viewer is never
* tagged) and is never set on private governed teams (those are hidden, not
* recommended). Asserted at the API layer so it proves the server-side annotation
* across the full api4 → app filter/annotate → store-listing chain, plus a UI check
* that the chip renders in the team-selection directory.
*
* @reference MM-69100
*/
test.describe('ABAC - Team directory recommended tag', {tag: ['@abac', '@team_membership']}, () => {
let cleanupClient: Client4 | undefined;
const createdTeamIds: string[] = [];
const createdUserIds: string[] = [];
const createdPolicyIds: string[] = [];
test.afterEach(async () => {
const client = cleanupClient;
cleanupClient = undefined;
const policyIds = createdPolicyIds.splice(0);
const teamIds = createdTeamIds.splice(0);
const userIds = createdUserIds.splice(0);
if (!client) {
return;
}
const base = client.getBaseRoute();
const headers = {Authorization: `Bearer ${client.getToken()}`};
for (const id of policyIds) {
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
}
for (const id of teamIds) {
await client.deleteTeam(id).catch(() => {});
}
for (const id of userIds) {
await client.updateUserActive(id, false).catch(() => {});
}
});
test('MM-69100-T1 - recommends a public governed team to a qualifying non-member only', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(qualUser.id, nonQualUser.id);
// A public (open-invite) governed team: advisory, so it stays visible to all;
// qualifying non-members get the recommended hint. open-invite is set via patch
// (the create payload does not persist it).
const publicGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Public Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Recommended Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// API layer: the qualifying non-member's listing tags the team recommended;
// the non-qualifying user's listing does not (fail-secure).
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
await expect
.poll(
async () => {
const teams = (await qualClient.getTeams(0, 200)) as Team[];
return teams.find((t) => t.id === publicGoverned.id)?.recommended === true;
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'public governed team should be recommended to the qualifying user',
},
)
.toBe(true);
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
const nonQualRow = nonQualTeams.find((t) => t.id === publicGoverned.id);
expect(nonQualRow).toBeDefined(); // advisory: still visible
expect(Boolean(nonQualRow?.recommended)).toBe(false);
});
test('MM-69100-T2 - never recommends a private governed team, even to a qualifying user', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
createdUserIds.push(qualUser.id);
// A private (non-open-invite) governed team: strict. It is surfaced to the
// qualifying user (directory widening) but must never carry the recommended tag.
const privateGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Private Governed ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(privateGoverned.id);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Private Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [privateGoverned.id]);
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
await expect
.poll(async () => (await adminClient.getTeam(privateGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
// The qualifying user can see the private governed team (strict widening), but it
// is never recommended — recommendation is a public-team-only, advisory concept.
await expect
.poll(
async () => {
const teams = (await qualClient.getTeams(0, 200)) as Team[];
return teams.some((t) => t.id === privateGoverned.id);
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'qualifying user should see the private governed team before asserting recommendation',
},
)
.toBe(true);
const teams = (await qualClient.getTeams(0, 200)) as Team[];
const row = teams.find((t) => t.id === privateGoverned.id);
expect(Boolean(row?.recommended)).toBe(false);
});
test('MM-69100-T3 - renders the Recommended chip in the team-selection directory for a qualifying user', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
createdUserIds.push(qualUser.id);
// Prefix with "AA" so the team sorts near the top of most environments.
// The UI check in Part 2 below walks InfiniteScroll regardless, so an
// exact page-0 guarantee is not required.
const teamId = pw.random.id();
const publicGoverned = await adminClient.createTeam({
name: `team-${teamId}`,
display_name: `AA Recommended${teamId}`,
type: 'O',
email: `${teamId}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Recommended UI Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
// Wait for the materialized attribute view to include the qualifying user
// before asserting policy propagation — the PDP reads from this view.
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
// The child policy row is written to the master DB; the directory query
// reads from a read replica. Poll policy_enforced until the replica has
// caught up so the EXISTS subquery in GetAllPage widens correctly.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'team should show policy_enforced=true before asserting recommended',
})
.toBe(true);
const {page} = await pw.testBrowser.login(qualUser);
await page.goto('/select_team');
await page.waitForLoadState('networkidle');
const chip = page
.locator('.signup-team-dir')
.filter({hasText: publicGoverned.display_name})
.getByLabel('Recommended based on your attributes');
await expect(chip).toBeVisible({timeout: 15000});
});
});

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
/**
* Enable team-membership ABAC end to end: the umbrella attribute-based access
* control setting plus the `TeamMembershipAccessControl` feature flag that gates
* the per-team System Console section. Both must be on for the team page to
* render the membership-policy toggle — mirroring the server enforcement gate.
*/
export async function enableTeamMembershipPolicies(client: Client4): Promise<void> {
await client.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
FeatureFlags: {
TeamMembershipAccessControl: true,
},
} as any);
}
/**
* Create a parent membership policy that can be assigned to a TEAM.
*
* Team assignment runs `child.Inherit(parent)` with the child at v0.3, which
* requires the parent to also be v0.3 — so (unlike the channel-side
* createParentPolicy, which is v0.2) this helper stamps v0.3 and a membership
* action. Returns the created policy (carries `id` and `name`).
*/
export async function createTeamMembershipParentPolicy(
client: Client4,
name: string,
expression: string,
): Promise<{id: string; name: string}> {
return (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies`, {
method: 'put',
body: JSON.stringify({
id: '',
name,
type: 'parent',
version: 'v0.3',
revision: 0,
rules: [{expression, actions: ['membership']}],
}),
});
}
/**
* Trigger an access_control_team_sync job and poll that specific job until it
* finishes. Polling by ID (not list position) avoids a race where older jobs
* occupy jobs[0] and the newly created one is never checked.
*
* Both `success` and `warning` are terminal completions: the worker reports
* `warning` (not `success`) whenever the mass-removal guardrail trips — i.e. a
* sync that drops >50% of a team. The sync still ran to completion, so callers
* that exercise removals must accept it. The final status is returned so a
* caller can assert on it (e.g. expecting the warning state).
*/
export async function triggerSyncJobAndPoll(
client: Client4,
policyId = '',
timeoutMs = 90_000,
pollIntervalMs = 3_000,
): Promise<string> {
// Scope the sync to the team's policy (team policies are keyed by team id),
// mirroring the product trigger createAccessControlTeamSyncJob({policy_id}).
// A scoped sync also chains a scoped channel sync, so the chained job is
// created deterministically rather than skipped by the unscoped dedupe.
const body: {type: string; data?: {policy_id: string}} = {type: 'access_control_team_sync'};
if (policyId) {
body.data = {policy_id: policyId};
}
const job: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs`, {
method: 'POST',
body: JSON.stringify(body),
});
const jobId: string = job.id;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const current: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs/${jobId}`, {method: 'GET'});
if (current.status === 'success' || current.status === 'warning') {
return current.status;
}
if (current.status === 'error') {
throw new Error(`access_control_team_sync job failed: ${JSON.stringify(current)}`);
}
}
throw new Error('Timed out waiting for access_control_team_sync job to finish');
}
/**
* Assign a parent policy to teams via the REST API (the `team_ids` field).
* Mirrors `assignChannelsToPolicy` from team_settings/helpers — same endpoint,
* different resource list.
*/
export async function assignTeamsToPolicy(client: Client4, policyId: string, teamIds: string[]): Promise<void> {
const url = `${client.getBaseRoute()}/access_control_policies/${policyId}/assign`;
const response = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${client.getToken()}`},
body: JSON.stringify({team_ids: teamIds}),
});
if (!response.ok) {
throw new Error(`assignTeamsToPolicy failed: ${response.status}`);
}
}

View File

@@ -0,0 +1,251 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective API-level enforcement gate: non-qualifying users are rejected when joining a private
* ABAC-governed team; the same call is allowed on a public (advisory) team.
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
ensureDepartmentAttribute,
enableTeamMembershipABACConfig,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
// Raw fetch wrapper — returns status + body so tests can assert on both success and rejection
// without doFetch swallowing the error.
async function addTeamMemberRaw(
token: string | null,
baseRoute: string,
teamId: string,
userId: string,
): Promise<{status: number; body: any}> {
const res = await fetch(`${baseRoute}/teams/${teamId}/members`, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
body: JSON.stringify({team_id: teamId, user_id: userId}),
});
let body: any = {};
try {
body = await res.json();
} catch {
// empty body is fine
}
return {status: res.status, body};
}
// Log in via raw REST to obtain the user's OWN session token, so a self-join can be
// attempted with the requesting user's session rather than the admin's (which would
// bypass the attribute gate). The token is returned in the response 'Token' header.
async function loginRaw(baseRoute: string, loginId: string, password: string): Promise<string> {
const res = await fetch(`${baseRoute}/users/login`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({login_id: loginId, password}),
});
return res.headers.get('Token') ?? '';
}
test.describe('ABAC - Join enforcement gate (API level)', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T8 non-qualifying user is rejected at the API level on a private ABAC team', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create private team governed by the Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 (qualifies) and mkt1 (does not qualify)
const makeUser = async (dept: string, label: string) => {
const uid = `${label}${suffix}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await makeUser('Engineering', 'eng1gate');
const mkt1 = await makeUser('Marketing', 'mkt1gate');
createdUserIds.push(eng1.id, mkt1.id);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// Wait for the materialized view to reflect both users before hitting the gate
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
const token = adminClient.getToken();
const baseRoute = adminClient.getBaseRoute();
// * Non-qualifying user (Marketing) is rejected with 403
const mktResult = await addTeamMemberRaw(token, baseRoute, team.id, mkt1.id);
expect(mktResult.status).toBe(403);
// * Error message is generic — must NOT leak policy name, attribute name or CEL expression
const errMsg: string = mktResult.body?.message ?? '';
expect(errMsg).not.toMatch(/Engineering/i);
expect(errMsg).not.toMatch(/Department/i);
expect(errMsg).not.toMatch(/policy/i);
expect(errMsg.length).toBeGreaterThan(0);
// * mkt1 is NOT a member of the team
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds).not.toContain(mkt1.id);
// * Qualifying user (Engineering) is accepted with 201 (Created)
const engResult = await addTeamMemberRaw(token, baseRoute, team.id, eng1.id);
expect(engResult.status).toBe(201);
// * eng1 is now a member
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIdsAfter = membersAfter.map((m: any) => m.user_id);
expect(memberIdsAfter).toContain(eng1.id);
});
test('MM-69100-T9 non-qualifying user is allowed at the API level on a public ABAC team (advisory)', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create public team — advisory mode, same Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
const uid = `mkt2gate${suffix}`;
const mkt2 = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, mkt2.id, 'Department', 'Marketing');
createdUserIds.push(mkt2.id);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
const token = adminClient.getToken();
const baseRoute = adminClient.getBaseRoute();
// * Non-qualifying user is allowed on a public ABAC team — advisory, no gate
const result = await addTeamMemberRaw(token, baseRoute, team.id, mkt2.id);
expect(result.status).toBe(201);
// * mkt2 is a member despite not matching the policy
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds).toContain(mkt2.id);
});
test('MM-69100-T10 qualifying user self-joins a private ABAC team by attribute match; non-qualifying is rejected', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team governed by the Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Two regular users (no join_private_teams role): eng qualifies, mkt does not.
// Capture their passwords so we can log in as them and self-join.
const makeUser = async (dept: string, label: string) => {
const uid = `${label}${suffix}`;
const password = newTestPassword();
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
createdUserIds.push(user.id);
return {user, password};
};
const eng = await makeUser('Engineering', 'engself');
const mkt = await makeUser('Marketing', 'mktself');
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng.user.id]);
const baseRoute = adminClient.getBaseRoute();
// # Qualifying user logs in and joins themselves with their OWN session
const engToken = await loginRaw(baseRoute, eng.user.username, eng.password);
expect(engToken.length).toBeGreaterThan(0);
// * Attribute match authorizes the self-join even without the join_private_teams role
const engResult = await addTeamMemberRaw(engToken, baseRoute, team.id, eng.user.id);
expect(engResult.status).toBe(201);
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
expect(members.map((m: any) => m.user_id)).toContain(eng.user.id);
// # Non-qualifying user attempts the same self-join with their own session
const mktToken = await loginRaw(baseRoute, mkt.user.username, mkt.password);
expect(mktToken.length).toBeGreaterThan(0);
// * Rejected — the attribute gate applies to the requesting user's session too
const mktResult = await addTeamMemberRaw(mktToken, baseRoute, team.id, mkt.user.id);
expect(mktResult.status).toBe(403);
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
expect(membersAfter.map((m: any) => m.user_id)).not.toContain(mkt.user.id);
});
});

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective A team sync removal cascades to private channel membership.
*
* When a user is removed from a private ABAC-governed team, the server calls
* LeaveTeam which synchronously removes that user from every private channel
* in the team. This spec exercises the full path end-to-end:
*
* policy update → team sync → team removal → channel cascade (LeaveTeam)
*
* Two users start in both the team and a private channel. Updating the policy
* to exclude one of them removes that user from the team AND the channel, while
* the qualifying user is unaffected.
*
* Assertions run at two layers:
* 1. API — direct membership queries confirm the server state.
* 2. UI — browser logins confirm what each user actually sees rendered.
*
* @reference MM-69100
*/
import {ChannelsPage, expect, newTestPassword, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import {
createPrivateChannel,
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
test.describe('ABAC - Team Sync Channel Cascade', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(240000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
await adminClient.deleteTeam(id).catch(() => {});
}
createdTeamIds = [];
for (const id of createdUserIds) {
await adminClient.updateUserActive(id, false).catch(() => {});
}
createdUserIds = [];
});
/**
* When the sync job removes a non-qualifying user from a private team, the
* underlying LeaveTeam call also removes them from every private channel in
* that team. Validated at both the API layer (membership queries) and the UI
* layer (browser logins for each affected user).
*/
test('MM-69100-T_cascade - team sync removal cascades to private channel membership', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
if (!adminClient) {
throw new Error('Admin client not available');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team (allow_open_invite=false → strict ABAC enforcement)
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
const {adminUser} = await pw.getAdminClient();
if (adminUser) {
await adminClient.removeFromTeam(team.id, adminUser.id).catch(() => {});
}
// # Private channel inside the governed team — the cascade target
const privateChannel = await createPrivateChannel(adminClient, team.id);
// # Public home team for mkt1. A user removed from their only team lands
// # on a "Team not found" error and cannot navigate anywhere meaningful.
// # homeTeam gives mkt1 a valid context for the UI assertions below.
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
createdTeamIds.push(homeTeam.id);
// # Create eng1 (Engineering) and mkt1 (Marketing) — track passwords for browser login
const eng1Password = newTestPassword();
const eng1 = await adminClient.createUser(
{
email: `eng1${suffix}@sample.mattermost.com`,
username: `eng1${suffix}`,
password: eng1Password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, eng1.id, 'Department', 'Engineering');
const mkt1Password = newTestPassword();
const mkt1 = await adminClient.createUser(
{
email: `mkt1${suffix}@sample.mattermost.com`,
username: `mkt1${suffix}`,
password: mkt1Password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
await adminClient.addToTeam(homeTeam.id, mkt1.id);
createdUserIds.push(eng1.id, mkt1.id);
// Gate on the materialized attribute view — ABAC sync reads from a Postgres
// view that refreshes at most once every 30 s. Freshly-written attributes
// are not visible until the next refresh tick.
await waitForAttributeViewToInclude(
adminClient,
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
[eng1.id, mkt1.id],
);
// =========================================================
// Phase 1 — policy admits both; sync auto-adds both to team
// =========================================================
await createTeamMembershipPolicy(
adminClient,
team.id,
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
true, // active = auto-add
);
await triggerSyncJobAndPoll(adminClient, team.id);
// * [API] Both are team members after first sync
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 15000, intervals: [500, 1000, 2000], message: 'both users should be auto-added to team'},
)
.toEqual(expect.arrayContaining([eng1.id, mkt1.id]));
// Team sync adds users to the team (and town-square) only — not to
// arbitrary private channels. Add both manually so the cascade has something to remove.
await adminClient.addToChannel(eng1.id, privateChannel.id);
await adminClient.addToChannel(mkt1.id, privateChannel.id);
// * [API] Both in private channel — baseline confirmed
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, mkt1.id, privateChannel.id)).toBe(true);
// =========================================================
// Phase 2 — policy narrows to Engineering; mkt1 loses access
// =========================================================
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// Strict mode: sync removes mkt1 from team; LeaveTeam cascades to channels
await triggerSyncJobAndPoll(adminClient, team.id);
// * [API] mkt1 removed from team
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 15000, intervals: [500, 1000, 2000], message: 'mkt1 should be removed from team'},
)
.not.toContain(mkt1.id);
// * [API] mkt1 cascade-removed from private channel
await expect
.poll(() => verifyUserInChannel(adminClient, mkt1.id, privateChannel.id), {
timeout: 15000,
intervals: [500, 1000, 2000],
message: 'mkt1 should be cascade-removed from private channel',
})
.toBe(false);
// * [API] eng1 still in team and channel
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 10000, intervals: [500, 1000], message: 'eng1 should remain in team'},
)
.toContain(eng1.id);
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
// =========================================================
// UI — mkt1: governed team gone from sidebar; channel inaccessible
// =========================================================
const mkt1WithPassword = {...mkt1, password: mkt1Password};
const {page: mkt1Page} = await pw.testBrowser.login(mkt1WithPassword);
const mkt1ChannelsPage = new ChannelsPage(mkt1Page);
// Land on homeTeam so we have a valid context to inspect the sidebar from
await mkt1ChannelsPage.goto(homeTeam.name, 'town-square');
await mkt1ChannelsPage.toBeVisible();
// * Navigating to the governed team URL redirects mkt1 away (no membership)
await mkt1Page.goto(`/${team.name}/channels/town-square`);
await mkt1Page.waitForLoadState('networkidle');
await expect(mkt1Page).not.toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
// =========================================================
// UI — eng1: governed team and private channel still accessible
// =========================================================
const eng1WithPassword = {...eng1, password: eng1Password};
const {page: eng1Page} = await pw.testBrowser.login(eng1WithPassword);
const eng1ChannelsPage = new ChannelsPage(eng1Page);
// * eng1 can access the governed team — URL confirms they are on it
// (team sidebar icon only renders when the user belongs to multiple teams,
// so we assert the route instead of the sidebar element)
await eng1ChannelsPage.goto(team.name, 'town-square');
await eng1ChannelsPage.toBeVisible();
await expect(eng1Page).toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
// * Private channel is accessible to eng1
await eng1ChannelsPage.goto(team.name, privateChannel.name);
await eng1ChannelsPage.toBeVisible();
await expect(eng1Page.locator('#channelHeaderTitle', {hasText: privateChannel.display_name})).toBeVisible({
timeout: 10000,
});
});
});

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Sync Job Details modal shows per-team added/removed counts and the mass-removal warning
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPrivateTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
test.describe('ABAC - Sync Job Details Modal', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T4 sync job details shows per-team member counts and section title', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # The creator is auto-added; drop them so the member set is exactly the
// # two users below — keeps the removal count deterministic and below the
// # >50% mass-removal guardrail.
await adminClient.removeFromTeam(team.id, adminUser.id);
// # eng1 is a qualifying member; mkt1 is non-qualifying (will be removed by sync)
const createUser = async (dept: string, label: string, addToTeam: boolean) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
if (addToTeam) {
await adminClient.addToTeam(team.id, user.id);
}
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`, true);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`, true);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Active policy (strict mode removes non-qualifiers)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for completion
await triggerSyncJobAndPoll(adminClient, team.id);
// # Log in and navigate to sync jobs page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// * Section title is "Membership sync jobs" (capital S verified in en.json)
await expect(page.getByText(/Membership sync jobs/i)).toBeVisible({timeout: 10000});
// # Open the most recent job's details (the channel sync chained from the team sync)
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
await expect(jobRow).toBeVisible({timeout: 10000});
// * Row exposes a visible "View details" affordance (MM-69827 finding 12)
await expect(jobRow.getByText('View details')).toBeVisible();
await jobRow.click();
const detailsModal = page.locator('#job-details-modal');
await expect(detailsModal).toBeVisible({timeout: 15000});
// # Switch to the Teams tab where the team membership changes are surfaced
await detailsModal.getByRole('button', {name: 'Teams'}).click();
// * Team row present for our team
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
await expect(teamRow).toBeVisible({timeout: 10000});
// * Removed count shows -1 (mkt1 removed)
await expect(teamRow.locator('.changes-cell .removed')).toHaveText(/-1/);
// # Click team row to open the user drill-down modal
await teamRow.click();
await expect(page.locator('#user-list-modal-dialog')).toBeVisible({timeout: 10000});
});
test('MM-69827-T1 Membership Sync Jobs names the channel scope and points to team sync when team ABAC is on', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// * Button names its scope so a green success isn't mistaken for a team re-sync
await expect(page.getByRole('button', {name: /Run Channel Sync/i})).toBeVisible({timeout: 10000});
// * A note points admins to where team-membership sync actually lives
await expect(page.getByText(/This syncs channel membership only/i)).toBeVisible({timeout: 10000});
await expect(page.getByText(/Team Settings/i)).toBeVisible();
});
test('MM-69100-T5 sync job details shows mass-removal warning when >50% of members are removed', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # The creator is auto-added; drop them so the member set is exactly the
// # four users below (1 qualifying + 3 not), giving a clean 75% removal.
await adminClient.removeFromTeam(team.id, adminUser.id);
// # Add 1 qualifying member and 3 non-qualifying members so >50% are removed
const createAndAdd = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await adminClient.addToTeam(team.id, user.id);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createAndAdd('Engineering', `eng${suffix}`);
const mkt1 = await createAndAdd('Marketing', `mkt1${suffix}`);
const mkt2 = await createAndAdd('Marketing', `mkt2${suffix}`);
const mkt3 = await createAndAdd('Marketing', `mkt3${suffix}`);
createdUserIds.push(eng1.id, mkt1.id, mkt2.id, mkt3.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Active policy — 3 of 4 members removed (75%)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for completion
await triggerSyncJobAndPoll(adminClient, team.id);
// # Navigate to sync jobs page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Open the most recent job's details (the channel sync chained from the team sync)
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
await expect(jobRow).toBeVisible({timeout: 10000});
await jobRow.click();
const detailsModal = page.locator('#job-details-modal');
await expect(detailsModal).toBeVisible({timeout: 15000});
// # Switch to the Teams tab where the team membership changes are surfaced
await detailsModal.getByRole('button', {name: 'Teams'}).click();
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
await expect(teamRow).toBeVisible({timeout: 10000});
// * Mass-removal warning indicator present (>50% removed)
await expect(teamRow.locator('.mass-removal-warning')).toBeVisible({timeout: 10000});
});
});

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective A real team sync job creates the addition/removal system posts, which are
* rendered correctly in the affected user's DM from the system bot.
* @reference MM-69100
*/
import {ChannelsPage, expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPublicTeam,
createPrivateTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
const SYSTEM_BOT_USERNAME = 'system-bot';
test.describe('ABAC - Sync System Messages', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T6 qualifying non-member receives an addition DM after a sync job', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Public team + active Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create joiner with Engineering attribute — NOT yet a team member
const joinerUid = `joiner${suffix}`;
const joinerPassword = newTestPassword();
const joiner = await adminClient.createUser(
{
email: `${joinerUid}@sample.mattermost.com`,
username: joinerUid,
password: joinerPassword,
} as any,
'',
'',
);
createdUserIds.push(joiner.id);
await setUserAttribute(adminClient, joiner.id, 'Department', 'Engineering');
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [joiner.id]);
// # Active policy — auto-add will add joiner on next sync
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for it to complete
await triggerSyncJobAndPoll(adminClient);
// * joiner is now a team member
const members: any[] = await adminClient.getTeamMembers(team.id);
const joinerMember = members.find((m: any) => m.user_id === joiner.id);
expect(joinerMember).toBeDefined();
// # Locate the system-bot user
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
expect(botUsers.length).toBeGreaterThan(0);
const systemBot = botUsers[0];
// # Get or create the DM channel between joiner and system-bot
const dmChannel = await adminClient.createDirectChannel([joiner.id, systemBot.id]);
expect(dmChannel).toBeDefined();
// # Log in as joiner and navigate to the DM
const joinerWithPassword = {...joiner, password: joinerPassword};
const {page} = await pw.testBrowser.login(joinerWithPassword);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, dmChannel.name);
await channelsPage.toBeVisible();
// * Addition system message rendered in the DM (scoped to this run's team name
// to avoid matching accumulated messages from prior test runs in the same DM).
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const expectedText = new RegExp(
`You have been added to ${escapedName} because you now meet the membership requirements`,
'i',
);
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
timeout: 15000,
});
});
test('MM-69100-T7 removed member receives a removal DM after a sync job', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # A second, non-governed team that mkt1 keeps after being removed from the
// governed one. A user with zero teams lands on a "Team Not Found" page and
// can't reach the system-bot DM, so they need a team context to view it.
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
createdTeamIds.push(homeTeam.id);
// # mkt1 has Marketing — does not match Engineering rule; IS a current member
const mkt1Uid = `mkt1${suffix}`;
const mkt1Password = newTestPassword();
const mkt1 = await adminClient.createUser(
{
email: `${mkt1Uid}@sample.mattermost.com`,
username: mkt1Uid,
password: mkt1Password,
} as any,
'',
'',
);
createdUserIds.push(mkt1.id);
await adminClient.addToTeam(team.id, mkt1.id);
await adminClient.addToTeam(homeTeam.id, mkt1.id);
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Marketing"', [mkt1.id]);
// # Active policy (strict) — mkt1 will be removed
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
await triggerSyncJobAndPoll(adminClient, team.id);
// * mkt1 is no longer a team member
const members: any[] = await adminClient.getTeamMembers(team.id);
const mkt1Member = members.find((m: any) => m.user_id === mkt1.id);
expect(mkt1Member).toBeUndefined();
// # Ensure the DM channel between mkt1 and system-bot exists before logging in
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
const systemBot = botUsers[0];
const dmChannel = await adminClient.createDirectChannel([mkt1.id, systemBot.id]);
// # Log in as mkt1 and open the system-bot DM from the retained team's context
const mkt1WithPassword = {...mkt1, password: mkt1Password};
const {page} = await pw.testBrowser.login(mkt1WithPassword);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(homeTeam.name, dmChannel.name);
await channelsPage.toBeVisible();
// * Removal system message rendered (scoped to this run's team name).
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const expectedText = new RegExp(
`You have been removed from ${escapedName} because you no longer meet the membership requirements`,
'i',
);
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
timeout: 15000,
});
});
});

View File

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
} from '../support';
/**
* ABAC User Attributes - Attribute Changes
* Tests for user attribute changes affecting ABAC policies
*/
test.describe('ABAC User Attributes - Attribute Changes', () => {
/**
* MM-T5794: User is auto-added to channel when a qualifying attribute is added to their profile (auto-add true)
*
* Step 1:
* With at least one access policy in existence on the server, set to auto-add, and applied to a channel:
* 1. As system admin make a note of the attribute needed for a user to be auto-added to a channel
* 2. As a user not in the channel and not having the required attribute
* 3. Click user's own profile picture top right and select Profile
* 4. Scroll down to the required custom attribute, click Edit, and add the required value
*/
test('MM-T5794 User auto-added when qualifying attribute is added to profile', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create attribute, policy, and channel
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
// Setup attributes (using ensureUserAttributes like MM-T5800 does)
await ensureUserAttributes(adminClient);
// Create test user with NON-qualifying Department attribute (same pattern as MM-T5800)
// MM-T5800 creates user with Department=Sales, then changes to Engineering
// We do the same: Start with Sales (non-qualifying), then change to Engineering (qualifying)
const testUser = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, testUser.id);
// Create private channel
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// STEP 1: Create ABAC policy with auto-add enabled
// Policy requirement: Department == "Engineering"
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `Engineering Access ${pw.random.id()}`;
const jobId = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // ✅ Auto-add enabled
channels: [privateChannel.display_name],
});
// Activate policy (EXACT same pattern as MM-T5800)
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobId);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(adminClient, policyId);
}
await searchInput.clear();
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and sync
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// ============================================================
// STEP 2: Verify user is NOT in channel initially
// ============================================================
const syncJob1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob1);
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
expect(initialInChannel).toBe(false);
// ============================================================
// STEPS 3-5: Add qualifying attribute to user's profile
// Note: Using API for attribute update. UI testing for profile editing
// is covered in separate user profile test suite.
// ============================================================
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
// ============================================================
// STEP 6: Run sync job to trigger auto-add
// ============================================================
// DEBUG: Verify attribute was updated before sync
await adminClient.getUserCustomProfileAttributesValues(testUser.id);
// Get the Department field to check its value
await adminClient.getCustomProfileAttributeFields();
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2);
// ============================================================
// VERIFICATION: User should now be auto-added to channel
// ============================================================
// DEBUG: Check all channel members
await adminClient.getChannelMembers(privateChannel.id);
const finalInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
if (!finalInChannel) {
// console.error('\n[ERROR] User NOT in channel after sync!');
// console.error('[ERROR] This means the ABAC sync did not add the user.');
// console.error('[ERROR] Possible causes:');
// console.error('[ERROR] 1. Policy not active');
// console.error('[ERROR] 2. Attribute value not matching policy');
// console.error('[ERROR] 3. Sync job failed silently');
}
expect(finalInChannel).toBe(true);
// ============================================================
// VERIFICATION: Check for "User added" system message
// ============================================================
// Get recent posts from the channel
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
const postList = posts.order.map((postId: string) => posts.posts[postId]);
// Find system message for user being added
const userAddedMessage = postList.find((post: any) => {
return (
post.type === 'system_add_to_channel' &&
post.props?.addedUserId === testUser.id &&
post.user_id === 'system'
);
});
if (userAddedMessage) {
// System message found
} else {
// System message not found (may be disabled in test env)
}
// System messages might be disabled in test env, so we don't fail the test
// The important verification is that the user was added
expect(finalInChannel).toBe(true);
});
});

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
verifyUserInChannel,
updateUserAttributes,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
createPrivateChannelForABAC,
createBasicPolicy,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC User Attributes - Attribute Changes
* Tests for user attribute changes affecting ABAC policies
*/
test.describe('ABAC User Attributes - Attribute Changes', () => {
/**
* MM-T5795: User can be added to channel by system admin after a qualifying attribute is added to their profile (auto-add false)
*
* Preconditions:
* - Access policy with auto-add set to FALSE
*
* Steps:
* 1. As system admin, note the required attribute for channel access
* 2. As a user not in the channel and lacking the required attribute:
* - Add the required attribute value to user profile
* 3. As system admin, go to the channel and add the user
*
* Expected:
* - User who now meets the policy CAN be added to the channel by the admin
* - "User added" message is posted in the channel by System
*/
test('MM-T5795 User can be added by admin after attribute added (auto-add false)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create attribute, policy with auto-add FALSE, and channel
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Create test user WITHOUT the qualifying attribute
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, []);
await adminClient.addToTeam(team.id, testUser.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// STEP 1: Create policy with auto-add DISABLED
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
});
const policyName = `Engineering Manual Add ${pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // ✅ Auto-add DISABLED
channels: [privateChannel.display_name],
});
// ============================================================
// STEP 2: Add qualifying attribute to user
// ============================================================
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
// ============================================================
// STEP 3: Admin manually adds user to channel
// ============================================================
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
});
// Re-create the Department CPA field if a concurrent initSetup() deleted it.
// Without the field the server returns "An attribute is missing from the expression"
// on addToChannel, because the policy references a field id that no longer exists.
await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Verify user can be added (policy allows it since user has qualifying attribute)
await adminClient.addToChannel(testUser.id, privateChannel.id);
// Membership + ABAC evaluation can lag behind the REST response in CI.
await expect
.poll(async () => verifyUserInChannel(adminClient, testUser.id, privateChannel.id), {
timeout: 60000,
intervals: [1000, 2000, 3000],
})
.toBe(true);
// ============================================================
// VERIFICATION: Check for "User added" system message
// ============================================================
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
const postList = posts.order.map((postId: string) => posts.posts[postId]);
const userAddedMessage = postList.find((post: any) => {
return post.type === 'system_add_to_channel' && post.props?.addedUserId === testUser.id;
});
if (userAddedMessage) {
// System message found
} else {
// System message not found (may be disabled in test env)
}
});
});

View File

@@ -0,0 +1,215 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC User Attributes - Attribute Changes
* Tests for user attribute changes affecting ABAC policies
*/
test.describe('ABAC User Attributes - Attribute Changes', () => {
/**
* MM-T5796: User is auto-removed from channel when required attribute is removed
*
* Test Scenario 1 & 2 (Auto-add: False & True):
* Steps:
* 1. As system admin, identify the required attribute for channel access
* 2. Log in as a user currently in the channel with the required attribute
* 3. Edit user's profile
* 4. Remove or change the required attribute value
* 5. Save changes
*
* Expected:
* - User is automatically removed from the channel
* - System posts a "User removed" message in the channel
*/
test('MM-T5796 User auto-removed when required attribute is removed', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Create test user WITH the qualifying attribute (starts with Department=Engineering)
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
await adminClient.addToTeam(team.id, testUser.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// TEST SCENARIO 1: Auto-add FALSE
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy1Name = `Engineering Access NoAutoAdd ${pw.random.id()}`;
const jobId1 = await createBasicPolicy(systemConsolePage.page, {
name: policy1Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // Auto-add FALSE
channels: [privateChannel.display_name],
});
// Manually add user to channel
await adminClient.addToChannel(testUser.id, privateChannel.id);
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
expect(initialInChannel).toBe(true);
// Get policy ID and activate
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobId1);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policy1Name.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policy1Name;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyElementId = await policyRow.getAttribute('id');
const policyId = policyElementId?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(adminClient, policyId);
}
await searchInput.clear();
// Remove the qualifying attribute
await updateUserAttributes(adminClient, testUser.id, {Department: 'Sales'});
// Wait for attribute change to propagate
await systemConsolePage.page.waitForTimeout(1000);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Run sync job
const syncJob1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob1);
// Wait for membership updates to apply
await systemConsolePage.page.waitForTimeout(1000);
// Verify user is removed
const userInChannelAfterRemoval = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
expect(userInChannelAfterRemoval).toBe(false);
// Check for removal system message
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
const postList = posts.order.map((postId: string) => posts.posts[postId]);
const userRemovedMessage = postList.find((post: any) => {
return (
(post.type === 'system_remove_from_channel' || post.type === 'system_leave_channel') &&
(post.props?.removedUserId === testUser.id || post.user_id === testUser.id)
);
});
if (userRemovedMessage) {
// System message found
} else {
// System message not found (may be disabled in test env)
}
// ============================================================
// TEST SCENARIO 2: Auto-add TRUE
// ============================================================
// Restore user attribute and create new policy with auto-add=true
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
await navigateToABACPage(systemConsolePage.page);
const policy2Name = `Engineering Access WithAutoAdd ${pw.random.id()}`;
const jobId2 = await createBasicPolicy(systemConsolePage.page, {
name: policy2Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add TRUE
channels: [channel2.display_name],
});
// Activate and run sync to auto-add user
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobId2);
await searchInput.fill(policy2Name.match(/([a-z0-9]+)$/i)?.[1] || policy2Name);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow2 = systemConsolePage.page.locator('.policy-name').first();
const policyId2 = (await policyRow2.getAttribute('id'))?.replace('customDescription-', '');
if (policyId2) {
await activatePolicy(adminClient, policyId2);
}
await searchInput.clear();
// Re-apply ABAC enable guard: a concurrent initSetup() on another shard may have
// disabled ABAC between the initial enableABAC call and this sync job.
// Without ABAC enabled the server skips policy evaluation and won't auto-add the user.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2);
const userAutoAdded = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
expect(userAutoAdded).toBe(true);
// Remove attribute again
await updateUserAttributes(adminClient, testUser.id, {Department: 'Marketing'});
// Wait for attribute change to propagate
await systemConsolePage.page.waitForTimeout(1000);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Run sync
const syncJob3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob3);
// Small delay for channel membership update
await systemConsolePage.page.waitForTimeout(1000);
// Verify user is removed
const userRemovedFromChannel2 = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
expect(userRemovedFromChannel2).toBe(false);
});
});

View File

@@ -0,0 +1,204 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {
deleteCustomProfileAttributes,
setupCustomProfileAttributeFields,
} from '../../../channels/custom_profile_attributes/helpers';
import {fillSingleConditionValue, getPolicyIdByName} from '../support';
type FieldsMap = Record<string, UserPropertyField>;
async function clearExistingFields(client: Client4): Promise<void> {
try {
const existing = await client.getCustomProfileAttributeFields();
if (existing?.length) {
const map: FieldsMap = {};
for (const f of existing) {
map[f.id] = f;
}
await deleteCustomProfileAttributes(client, map);
}
} catch {
// No fields to clean up
}
}
test.describe('ABAC Attribute Selector - display_name rendering and filtering', () => {
/**
* @objective Verify the attribute selector renders display_name when set,
* falls back to `name`, filters on both, and persists the CEL identifier
* in saved policy expressions.
*
* @precondition
* Two admin-managed CPA fields seeded via REST: `dept_head` with
* display_name 'Department Head', and `office` with no display_name.
*/
test(
'renders and filters by display_name while persisting CEL identifier',
{tag: '@user_attributes'},
async ({pw}) => {
test.setTimeout(120000);
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await clearExistingFields(adminClient);
const seedAttributes: CustomProfileAttribute[] = [
{
name: 'dept_head',
type: 'text',
attrs: {
display_name: 'Department Head',
visibility: 'when_set',
managed: 'admin',
},
},
{
name: 'office',
type: 'text',
attrs: {
visibility: 'when_set',
managed: 'admin',
},
},
];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, seedAttributes);
const policyName = `Display Name Selector ${pw.random.id()}`;
let policyId: string | null = null;
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await navigateToABACPage(page);
await enableABAC(page);
// # Open the new-policy form
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyName);
// # Add an attribute rule and open its selector
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await expect(addAttributeButton).toBeEnabled({timeout: 10000});
await addAttributeButton.click();
const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await attributeButton.waitFor({state: 'visible', timeout: 5000});
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
if (!(await attributeMenu.isVisible({timeout: 1000}).catch(() => false))) {
await attributeButton.click();
}
await attributeMenu.waitFor({state: 'visible', timeout: 5000});
const deptHeadItem = page.locator('[id^="attribute-selector-menu"] li:has-text("Department Head")');
const officeItem = page.locator('[id^="attribute-selector-menu"] li:has-text("office")');
// * Both fields render: 'Department Head' (display_name) and 'office' (name fallback)
await expect(deptHeadItem).toBeVisible();
await expect(officeItem).toBeVisible();
const filterInput = attributeMenu.locator('.attribute-selector-search input');
await filterInput.waitFor({state: 'visible', timeout: 5000});
// * Filter by display_name keeps only 'Department Head'
await filterInput.fill('department');
await expect(deptHeadItem).toBeVisible();
await expect(officeItem).toHaveCount(0);
// * Filter by CEL identifier keeps only 'Department Head'
await filterInput.fill('');
await filterInput.fill('dept_head');
await expect(deptHeadItem).toBeVisible();
await expect(officeItem).toHaveCount(0);
// * Filter on the no-display_name field's `name` keeps only 'office'
await filterInput.fill('');
await filterInput.fill('office');
await expect(officeItem).toBeVisible();
await expect(deptHeadItem).toHaveCount(0);
// # Select 'Department Head'
await filterInput.fill('');
await deptHeadItem.first().click({force: true});
// * The trigger button shows display_name, not the CEL identifier
await expect(attributeButton).toContainText('Department Head', {timeout: 5000});
// # Wait for the attribute-selector popover to close before opening the next menu
const attributeMenuPopover = page.locator('[id^="attribute-selector-menu"]');
await attributeMenuPopover.waitFor({state: 'hidden', timeout: 5000});
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
await operatorButton.waitFor({state: 'visible', timeout: 5000});
await operatorButton.click();
const operatorMenu = page.locator('[id^="operator-selector-menu"]');
await operatorMenu.waitFor({state: 'visible', timeout: 5000});
await operatorMenu.locator('li:has-text("is")').first().click();
await fillSingleConditionValue(page, 'engineering');
const saveButton = page.getByRole('button', {name: 'Save'});
await expect(saveButton).toBeEnabled({timeout: 10000});
// # Click Save and wait on the create-policy PUT (button unmounts on navigate)
const createPolicyResponse = page.waitForResponse(
(r) => /\/access_control_policies(?:\?|$)/.test(r.url()) && r.request().method() === 'PUT',
{timeout: 15000},
);
await saveButton.click();
const createResponse = await createPolicyResponse;
expect(createResponse.ok()).toBe(true);
await page.waitForURL(/\/admin_console\/system_attributes\/membership_policies/, {timeout: 10000});
// * The persisted CEL uses the canonical identifier, not display_name
policyId = await getPolicyIdByName(adminClient, policyName);
expect(policyId).not.toBeNull();
const policy = await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
{method: 'GET'},
);
const rules = (policy?.rules || []) as Array<{actions?: string[]; expression?: string}>;
const membershipRule = rules.find((r) => r.actions?.includes('membership')) || rules[0];
expect(membershipRule).toBeDefined();
expect(membershipRule?.expression || '').toContain('user.attributes.dept_head');
expect(membershipRule?.expression || '').not.toContain('Department Head');
} finally {
if (policyId) {
try {
await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
{method: 'DELETE'},
);
} catch {
// best-effort cleanup
}
}
await deleteCustomProfileAttributes(adminClient, fieldsMap);
}
},
);
});