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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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