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,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
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user