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
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:
@@ -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}});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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} == "--------"`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
1562
e2e-tests/playwright/specs/functional/system_console/abac/support.ts
Normal file
1562
e2e-tests/playwright/specs/functional/system_console/abac/support.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user