mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
Merge b66675412b into cfafefe58c
This commit is contained in:
commit
3626a78e2c
11 changed files with 2805 additions and 0 deletions
|
|
@ -68,6 +68,7 @@ services:
|
|||
MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES: "true"
|
||||
MM_FEATUREFLAGS_PERMISSIONPOLICIES: "true"
|
||||
MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS: "true"
|
||||
MM_FEATUREFLAGS_ATTRIBUTEVALUEMASKING: "true"
|
||||
MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
|
||||
MM_LOGSETTINGS_CONSOLELEVEL: "DEBUG"
|
||||
network_mode: host
|
||||
|
|
@ -308,6 +309,7 @@ $(if mme2e_is_token_in_list "playwright" "$ENABLED_DOCKER_SERVICES"; then
|
|||
PW_ADMIN_PASSWORD: Sys@dmin-sample1
|
||||
PW_ADMIN_EMAIL: sysadmin@sample.mattermost.com
|
||||
PW_ENSURE_PLUGINS_INSTALLED: ""
|
||||
MM_TEST_DB_URL: "postgres://mmuser:mostest@localhost:5432/mattermost_test?sslmode=disable&connect_timeout=10&binary_parameters=yes"
|
||||
PW_HA_CLUSTER_ENABLED: "false"
|
||||
PW_RESET_BEFORE_TEST: "false"
|
||||
PW_HEADLESS: "true"
|
||||
|
|
|
|||
174
e2e-tests/playwright/package-lock.json
generated
174
e2e-tests/playwright/package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dayjs": "1.11.20",
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
"eslint-plugin-import": "2.32.0",
|
||||
"glob": "13.0.6",
|
||||
"luxon": "3.7.2",
|
||||
"pg": "8.13.1",
|
||||
"prettier": "3.8.2",
|
||||
"typescript": "6.0.2",
|
||||
"zod": "4.3.6"
|
||||
|
|
@ -1370,6 +1372,18 @@
|
|||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
|
||||
"integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
|
|
@ -5364,6 +5378,103 @@
|
|||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.13.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz",
|
||||
"integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.7.0",
|
||||
"pg-pool": "^3.7.0",
|
||||
"pg-protocol": "^1.7.0",
|
||||
"pg-types": "^2.1.0",
|
||||
"pgpass": "1.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -5423,6 +5534,49 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -6044,6 +6198,16 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash-x": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz",
|
||||
|
|
@ -6603,6 +6767,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"devDependencies": {
|
||||
"@playwright/test": "1.59.1",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dayjs": "1.11.20",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"eslint-plugin-import": "2.32.0",
|
||||
"glob": "13.0.6",
|
||||
"luxon": "3.7.2",
|
||||
"pg": "8.13.1",
|
||||
"prettier": "3.8.2",
|
||||
"typescript": "6.0.2",
|
||||
"zod": "4.3.6"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
// 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 {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
|
||||
import {
|
||||
createMaskingTextField,
|
||||
createPolicyWithCEL,
|
||||
deleteCPAField,
|
||||
deletePolicy,
|
||||
disableMaskingFlag,
|
||||
enableMaskingFlag,
|
||||
getPolicyIdFromURL,
|
||||
getRawPolicyExpression,
|
||||
openExistingPolicy,
|
||||
searchPoliciesExpression,
|
||||
setUserAttribute,
|
||||
} from './support';
|
||||
|
||||
/**
|
||||
* Attribute-Value Masking — text-field single-value masking, GET/search API
|
||||
* redaction, and warning-banner visibility.
|
||||
*/
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await purgeFieldsByPrefix('Masking');
|
||||
});
|
||||
|
||||
test('MM-68508-11: Text field masking with single-value operator (value not held)', async ({pw}) => {
|
||||
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 = `MaskingLocation_${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);
|
||||
// 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); // UNPLUG: remove to skip masking setup
|
||||
|
||||
await openExistingPolicy(page, policyName);
|
||||
|
||||
// "Building 7" is not held by the admin → it should be masked in some form
|
||||
// (masked chip, disabled input, or redacted placeholder)
|
||||
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();
|
||||
} 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-12: GET /policies/{id} does not leak raw CEL when values are masked', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
// 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); // UNPLUG: remove to skip masking setup
|
||||
|
||||
// Get the policy ID from the URL after navigating to it
|
||||
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 — "Bravo" and
|
||||
// "Charlie" would leak otherwise.
|
||||
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}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
// 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); // UNPLUG: remove to skip masking setup
|
||||
|
||||
// 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,
|
||||
// bypassing the API masking pipeline.
|
||||
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}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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: validatePolicyExpressionValues would
|
||||
// otherwise reject values the caller does not hold.
|
||||
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();
|
||||
|
||||
// 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
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
// 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 {enableUserManagedAttributes} from '../support';
|
||||
|
||||
import {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
|
||||
import {
|
||||
createMaskingTextField,
|
||||
createPolicyWithCEL,
|
||||
deleteCPAField,
|
||||
deletePolicy,
|
||||
disableMaskingFlag,
|
||||
enableMaskingFlag,
|
||||
getPolicyIdFromURL,
|
||||
openExistingPolicy,
|
||||
setUserAttribute,
|
||||
} from './support';
|
||||
|
||||
/**
|
||||
* Attribute-Value Masking — delete-button gating, server-side DELETE 403,
|
||||
* multi-condition merge-on-save, and team-settings delete behavior.
|
||||
*/
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await purgeFieldsByPrefix('Masking');
|
||||
});
|
||||
|
||||
test('MM-68508-15: Delete button is disabled on masked policies; clean policies open the standard confirmation modal', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
|
||||
// shared_only must flip AFTER both policy saves: validatePolicyExpressionValues would
|
||||
// otherwise reject values the caller does not hold.
|
||||
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
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
// 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);
|
||||
|
||||
// Confirm masked state
|
||||
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();
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await expect(deleteBtn).toBeDisabled();
|
||||
}
|
||||
|
||||
// The DELETE handler requires the route's :policy_id segment to match
|
||||
// [A-Za-z0-9]+. If the id is malformed, the request 404s instead of
|
||||
// hitting the 403 guard — assert format up front so a mismatch is
|
||||
// surfaced clearly instead of being misread as missing 403 enforcement.
|
||||
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 API (flag off)
|
||||
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.
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const programFieldName = `MaskingProgram_${pw.random.id()}`;
|
||||
const clearanceFieldName = `MaskingClearance_${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: validatePolicyExpressionValues
|
||||
// would otherwise reject Bravo / Charlie / Secret / TopSecret which the caller doesn't hold.
|
||||
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()) {
|
||||
await expect(firstTrash).toBeDisabled();
|
||||
}
|
||||
|
||||
// Dirty the form via the policy name so Save enables. Masked rows themselves
|
||||
// are read-only — no chip removal or value-selector edit is possible. The
|
||||
// merge-on-save server logic runs on any save, regardless of which field
|
||||
// triggered the dirty state.
|
||||
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 — bypass API masking, all hidden values
|
||||
// must survive merge-on-save. The persisted CEL uses canonical id form
|
||||
// (`user.id_<userid>.id_<fieldid>`), so match on field ids, not names.
|
||||
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.
|
||||
// Updates use the collection endpoint with `id` in the body — there is
|
||||
// no PUT on /access_control_policies/{id}. The submitted expression
|
||||
// must use only values the caller holds, otherwise
|
||||
// validatePolicyExpressionValues 400s before the 403 guard runs.
|
||||
// Caller holds "Alpha" in programField and nothing in clearanceField,
|
||||
// so submitting just the program condition drops the masked clearance
|
||||
// condition → 403 from mergeExpressionWithMaskedValues.
|
||||
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-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.
|
||||
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 = `MaskingProgram_${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');
|
||||
|
||||
// Create the policy via system console (as system admin)
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// Assign team to policy so it shows up in team settings
|
||||
await adminClient.addToTeam(team.id, adminUser.id);
|
||||
try {
|
||||
await (adminClient as any).doFetch(
|
||||
`${(adminClient as any).getBaseRoute()}/access_control_policies/${policyId}/teams`,
|
||||
{method: 'POST', body: JSON.stringify({team_id: team.id})},
|
||||
);
|
||||
} catch {
|
||||
// best-effort assignment — test still validates button state
|
||||
}
|
||||
|
||||
// Open team settings modal as the same admin (who has masked values)
|
||||
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();
|
||||
|
||||
// Find and open the masked policy in the editor
|
||||
const policyRow = teamSettings.container.getByText(policyName).first();
|
||||
if (await policyRow.isVisible()) {
|
||||
await policyRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Delete button must be disabled — masked values present
|
||||
const deleteBtn = teamSettings.container
|
||||
.locator('.TeamPolicyEditor__section--delete button')
|
||||
.filter({hasText: 'Delete'});
|
||||
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await expect(deleteBtn).toBeDisabled();
|
||||
|
||||
// Remove the channel (if any) — button must STAY disabled due to masked values
|
||||
const removeLink = teamSettings.container.getByText('Remove').first();
|
||||
if (await removeLink.isVisible()) {
|
||||
await removeLink.click();
|
||||
await page.waitForTimeout(300);
|
||||
// Even with no channels, delete must remain disabled because of masked values
|
||||
await expect(deleteBtn).toBeDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
await teamSettings.close();
|
||||
}
|
||||
|
||||
// The DELETE route requires `policy_id` to match [A-Za-z0-9]+; a
|
||||
// malformed id 404s before reaching the 403 masked-values guard.
|
||||
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 {} // eslint-disable-line no-empty
|
||||
}
|
||||
try {
|
||||
await disableMaskingFlag(adminClient);
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
// 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 {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
|
||||
import {
|
||||
createMaskingTextField,
|
||||
createPolicyWithCEL,
|
||||
deleteCPAField,
|
||||
deletePolicy,
|
||||
disableMaskingFlag,
|
||||
enableMaskingFlag,
|
||||
getPolicyIdFromURL,
|
||||
openExistingPolicy,
|
||||
setUserAttribute,
|
||||
} from './support';
|
||||
|
||||
/**
|
||||
* Attribute-Value Masking — editor states.
|
||||
*
|
||||
* Covers callers with full visibility (no masked chip), new-policy creation
|
||||
* (no masking applied), the locked masked-row save round-trip, and Simple-editor
|
||||
* masking with the multi-value "in" operator.
|
||||
*/
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await purgeFieldsByPrefix('Masking');
|
||||
});
|
||||
|
||||
test('MM-68508-7: Caller holding all policy values sees them all unmasked', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
// 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); // UNPLUG: remove to skip masking setup
|
||||
|
||||
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'});
|
||||
if (await testRulesBtn.isVisible()) {
|
||||
await expect(testRulesBtn).not.toBeDisabled();
|
||||
}
|
||||
|
||||
// CEL mode is editable (no read-only)
|
||||
const advancedBtn = page.getByRole('button', {name: /advanced/i});
|
||||
if (await advancedBtn.isVisible()) {
|
||||
await advancedBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
const monacoEditor = page.locator('.monaco-editor').first();
|
||||
if (await monacoEditor.isVisible()) {
|
||||
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}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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});
|
||||
if ((await addAttributeBtn.isVisible()) && !(await addAttributeBtn.isDisabled())) {
|
||||
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; merge-on-save preserves hidden values', 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
|
||||
// anyway. This test asserts the locked state, then dirties the form via an
|
||||
// unrelated field (policy name) and verifies the server-side merge still preserves
|
||||
// the hidden values across save — the same merge invariant E2E-2 covers, with the
|
||||
// extra assertion that the locked UI doesn't break round-trip correctness.
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${pw.random.id()}`;
|
||||
const fieldId = await createMaskingTextField(adminClient, fieldName);
|
||||
fieldIds.push(fieldId);
|
||||
|
||||
// adminUser holds "Alpha"; policy has ["Bravo", "Charlie"] (admin holds none of these)
|
||||
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 ["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);
|
||||
const storedPolicyId = await getPolicyIdFromURL(page);
|
||||
|
||||
// No visible chips (admin holds none of the existing values); only masked chip
|
||||
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();
|
||||
|
||||
const saveBtn = page.getByRole('button', {name: 'Save'});
|
||||
|
||||
// Value selector on the masked row is locked. Both the menu button and the chip
|
||||
// remove icons sit inside the disabled selector; trying to edit through them
|
||||
// is a no-op for the caller.
|
||||
const valueSelector = page.locator('[data-testid="valueSelectorMenuButton"]').first();
|
||||
await expect(valueSelector).toHaveClass(/disabled/);
|
||||
|
||||
// Dirty the form via an unrelated input so Save enables.
|
||||
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
|
||||
await nameInput.fill(policyName + ' (edited)');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Save — must succeed despite the masked row being read-only.
|
||||
await saveBtn.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify via API (flag off): Bravo + Charlie still in the stored policy.
|
||||
// Alpha is NOT expected — this test's policy never contained Alpha and the
|
||||
// caller had no way to add it through the locked selector.
|
||||
const rawExpression = (await getStoredPolicyRuleExpressions(storedPolicyId))[0] ?? '';
|
||||
|
||||
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-10: Text field masking with "in" operator', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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 uses a text-field "in" with multiple values
|
||||
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); // UNPLUG: remove to skip masking setup
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
// 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.
|
||||
*
|
||||
* DB URL resolution order:
|
||||
* 1. MM_TEST_DB_URL env var
|
||||
* 2. default: postgres://mmuser:mostest@localhost/mattermost_test?sslmode=disable
|
||||
*/
|
||||
|
||||
import {Client} from 'pg';
|
||||
|
||||
const DEFAULT_DB_URL =
|
||||
'postgres://mmuser:mostest@localhost:5432/mattermost_test?sslmode=disable&connect_timeout=10&binary_parameters=yes';
|
||||
|
||||
function resolveDbUrl(): string {
|
||||
return process.env.MM_TEST_DB_URL ?? DEFAULT_DB_URL;
|
||||
}
|
||||
|
||||
async function runQuery<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
const client = new Client({connectionString: resolveDbUrl()});
|
||||
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 the key so the field matches a freshly-created public field.
|
||||
await runQuery(
|
||||
`UPDATE propertyfields
|
||||
SET attrs = (COALESCE(attrs, '{}'::jsonb) - 'access_mode'),
|
||||
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,586 @@
|
|||
// 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 {enableUserManagedAttributes} from '../support';
|
||||
|
||||
import {purgeFieldsByPrefix, setFieldAsSharedOnly, setFieldAsSourceOnly} from './masking_db_setup';
|
||||
import {
|
||||
createMaskingMultiselectField,
|
||||
createMaskingTextField,
|
||||
createPolicyWithCEL,
|
||||
deleteCPAField,
|
||||
deletePolicy,
|
||||
disableMaskingFlag,
|
||||
enableMaskingFlag,
|
||||
ensureRoleHasPermission,
|
||||
openExistingPolicy,
|
||||
setUserAttribute,
|
||||
} from './support';
|
||||
|
||||
/**
|
||||
* Attribute-Value Masking — Simple↔Advanced mode toggle stability, delegated
|
||||
* (team / channel) admin surfaces, hasAnyOf operator preservation through the
|
||||
* mask round-trip, and the channel members RHS attribute-tag filter.
|
||||
*/
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await purgeFieldsByPrefix('Masking');
|
||||
});
|
||||
|
||||
test('MM-68508-19: Mode toggle Simple → Advanced → Simple preserves all masked-row restrictions', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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 (it lives in policy_details,
|
||||
// not the editor).
|
||||
await expect(banner).toBeVisible();
|
||||
// CEL editor visible
|
||||
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();
|
||||
// Give TableEditor a beat to remount and re-fetch the AST. The
|
||||
// assertions below must hold *after* the remount completes — that
|
||||
// window is exactly where the pre-fix race lived.
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Banner must STILL be visible.
|
||||
await expect(banner).toBeVisible();
|
||||
// Masked chip must STILL be visible.
|
||||
await expect(maskedChip.first()).toBeVisible();
|
||||
// Delete button must STILL be disabled.
|
||||
await expect(deleteBtn).toBeDisabled();
|
||||
// Value selector on the masked row must be disabled (no edits to
|
||||
// values the caller couldn't see).
|
||||
const valueSelector = page.locator('[data-testid="valueSelectorMenuButton"]').first();
|
||||
if (await valueSelector.isVisible()) {
|
||||
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-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.
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
// The team_admin role's stored permissions on this server can lag the
|
||||
// model defaults — without manage_team_access_rules the Membership
|
||||
// Policies tab is hidden from the team settings modal and this test
|
||||
// fails before the masking assertions run.
|
||||
await ensureRoleHasPermission(adminClient, 'team_admin', 'manage_team_access_rules');
|
||||
|
||||
const teamAdmin = await createTeamAdmin(adminClient, team.id);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
|
||||
// Sysadmin enables ABAC via the UI (required to activate the PAP),
|
||||
// then creates a parent policy and assigns only channels from the
|
||||
// team administered by `teamAdmin`. The assigned private channel makes
|
||||
// SearchTeamAccessPolicies enforce self-inclusion, which `teamAdmin`
|
||||
// satisfies because they hold 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 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();
|
||||
|
||||
// The policy is team-scoped through its single-team channel
|
||||
// assignment, and `teamAdmin` satisfies its rule, so it MUST appear in
|
||||
// the team-admin policy list. Search by the unique name because the
|
||||
// list is paginated and prior tests can leave more than one page of
|
||||
// MaskingPolicy rows.
|
||||
const searchInput = teamSettings.container.locator('[data-testid="searchInput"]').first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
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(),
|
||||
);
|
||||
await searchInput.fill(policyName);
|
||||
await searchResponse.catch(() => {
|
||||
// Debounced search can occasionally settle from cached data; the
|
||||
// row assertion below is the source of truth.
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const policyRow = teamSettings.container.getByText(policyName).first();
|
||||
await expect(policyRow).toBeVisible();
|
||||
await policyRow.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Masking surfaces in the team-policy editor exactly as in the
|
||||
// system console — masked chip visible, Delete disabled.
|
||||
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();
|
||||
await expect(deleteBtn).toBeDisabled();
|
||||
|
||||
await teamSettings.close();
|
||||
|
||||
// Server enforces the same 403 regardless of which admin role
|
||||
// initiated the delete. team_id is required in the URL because the
|
||||
// team-admin permission path scopes by team.
|
||||
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 {} // eslint-disable-line no-empty
|
||||
}
|
||||
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. Channel admins never see the system
|
||||
// console — this is the only surface where they touch policy values.
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// adminClient is the sysadmin REST handle used to seed the channel-level
|
||||
// policy directly; the channel admin (user) drives the UI assertions.
|
||||
const {adminClient, user, team} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
// Same caveat as test 20: the channel_admin role on this server may
|
||||
// be missing manage_channel_access_rules, which hides the Membership
|
||||
// Policy tab in the channel settings modal.
|
||||
await ensureRoleHasPermission(adminClient, 'channel_admin', 'manage_channel_access_rules');
|
||||
|
||||
// The Membership Policy tab requires a private channel that the
|
||||
// caller has channel-admin permission over.
|
||||
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 = `MaskingProgram_${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 (id === channel.id,
|
||||
// type === "channel"). The channel settings access-rules tab renders
|
||||
// this via getAccessControlPolicy(channelId) — which goes through the
|
||||
// same MaskPolicyExpressions read-path masking as everything else.
|
||||
// Parent policies assigned to a channel would only surface in the
|
||||
// SystemPolicyIndicator (read-only), not in the editable TableEditor
|
||||
// where the masked chips render.
|
||||
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();
|
||||
|
||||
// Open channel settings via the lib helper so we don't depend on
|
||||
// hand-rolled header selectors. The Membership Policy tab is gated
|
||||
// by canManageChannelAccessRules — channel_admin has it.
|
||||
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();
|
||||
// The tab loads via getChannelPolicy → server returns the masked
|
||||
// view (FF on). Allow time for the AST round-trip to render chips.
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Same masking primitives as every other surface — the TableEditor
|
||||
// underneath is the same component.
|
||||
await expect(channelSettings.container.locator('.select__multi-value--masked').first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Server-side guard: direct DELETE by the channel admin must 403,
|
||||
// matching the team-admin and sysadmin paths and proving no role
|
||||
// bypasses the masked-values protection.
|
||||
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 {} // 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.
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingTeam_${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);
|
||||
|
||||
// Flip to shared_only AFTER saving so the initial save is not rejected.
|
||||
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".
|
||||
// Before the fix, the masked expression re-parsed as hasAllOf and the wrong label appeared.
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
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 = `MaskingPublic_${id}`;
|
||||
const sharedFieldName = `MaskingShared_${id}`;
|
||||
const sourceFieldName = `MaskingSource_${id}`;
|
||||
|
||||
// Create all three fields as public first — the API rejects protected
|
||||
// access modes (source_only / shared_only) without a source_plugin_id,
|
||||
// so we flip them via direct DB writes 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);
|
||||
|
||||
// Give the admin user a value for every field so the self-inclusion
|
||||
// check passes when the policy is saved.
|
||||
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);
|
||||
|
||||
// Flip access modes AFTER saving — same pattern as other masking tests.
|
||||
// The policy save runs validatePolicyExpressionValues, which would reject
|
||||
// values the caller does not hold if the field were already shared_only/
|
||||
// source_only at save time.
|
||||
await setFieldAsSharedOnly(sharedFieldId);
|
||||
await setFieldAsSourceOnly(sourceFieldId);
|
||||
|
||||
// Create a private channel and attach the policy.
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
|
||||
|
||||
// Navigate to the channel.
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// The enforcement cache is cold on the first request — the hook fetch
|
||||
// returns {} and the RHS renders no tags. Open the RHS, check; if the
|
||||
// public-field tag is not yet visible, reload and retry. The first
|
||||
// /attributes request from the browser warms the cache so subsequent
|
||||
// fetches return the correctly-filtered attribute set.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// The tag text is formatted as "${AttributeLabel}: ${value}" where AttributeLabel
|
||||
// is the result of formatAttributeName() — field names with underscores and mixed
|
||||
// case are split and title-cased. Assert on the attribute VALUE to avoid coupling
|
||||
// to the formatting logic.
|
||||
//
|
||||
// Public field (value "Alpha") MUST be visible.
|
||||
await expect(alertContainer.getByText(/:\s*Alpha/)).toBeVisible();
|
||||
|
||||
// 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 {} // eslint-disable-line no-empty
|
||||
}
|
||||
try {
|
||||
await disableMaskingFlag(adminClient);
|
||||
} catch {} // eslint-disable-line no-empty
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
// 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 {purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
|
||||
import {
|
||||
createMaskingTextField,
|
||||
createPolicyWithCEL,
|
||||
deleteCPAField,
|
||||
deletePolicy,
|
||||
disableMaskingFlag,
|
||||
enableMaskingFlag,
|
||||
openExistingPolicy,
|
||||
setUserAttribute,
|
||||
} from './support';
|
||||
|
||||
/**
|
||||
* Attribute-Value Masking — save-path validation.
|
||||
*
|
||||
* Covers:
|
||||
* - Self-inclusion failure when the caller has full visibility (no masked
|
||||
* values) and removes the value that lets them satisfy the rule.
|
||||
* - Write-path rejection of non-held values and the masked-token sentinel
|
||||
* submitted via direct API calls.
|
||||
* - Read-only state of the CEL editor when masked values are present.
|
||||
*/
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await purgeFieldsByPrefix('Masking');
|
||||
});
|
||||
|
||||
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.
|
||||
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 = `MaskingProgram_${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);
|
||||
|
||||
// Policy: MaskingProgram in ["Alpha"] — admin holds ALL values, no masking
|
||||
const policyName = `MaskingPolicy ${pw.random.id()}`;
|
||||
const policyId = await createPolicyWithCEL(page, policyName, `user.attributes.${fieldName} in ["Alpha"]`);
|
||||
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 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);
|
||||
|
||||
// Try to save — should be blocked (admin no longer satisfies the condition)
|
||||
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();
|
||||
|
||||
// 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}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
// 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); // UNPLUG: remove to skip masking setup
|
||||
|
||||
await openExistingPolicy(page, policyName);
|
||||
|
||||
// Confirm masking is active (sanity)
|
||||
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. Monaco doesn't set the DOM `readonly`
|
||||
// attribute unless `domReadOnly: true` is configured, and it isn't exposed
|
||||
// on `window`. 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()) ?? '';
|
||||
// Click is intercepted by the .view-lines overlay; focus the textarea
|
||||
// directly and dispatch keystrokes — Monaco routes them to its model.
|
||||
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();
|
||||
|
||||
// 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()) {
|
||||
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()) {
|
||||
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,263 @@
|
|||
// 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 {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
|
||||
import {
|
||||
createMaskingTextField,
|
||||
createPolicyWithCEL,
|
||||
deleteCPAField,
|
||||
deletePolicy,
|
||||
disableMaskingFlag,
|
||||
enableMaskingFlag,
|
||||
getPolicyIdFromURL,
|
||||
openExistingPolicy,
|
||||
setUserAttribute,
|
||||
} from './support';
|
||||
|
||||
/**
|
||||
* Attribute-Value Masking — Simple-editor masking display behaviors.
|
||||
*
|
||||
* Validates the masked-chip UI, dirty-form save (merge-on-save), and row-remove
|
||||
* lockdown for callers viewing a policy whose CPA field has been flipped to
|
||||
* shared_only. Self-inclusion and direct-API write-path validations live in
|
||||
* save_validation.spec.ts.
|
||||
*/
|
||||
|
||||
// Purge any orphaned Masking* CPA fields left by previous failed runs so we
|
||||
// don't hit the 200-field global limit mid-suite. Uses a direct DB delete
|
||||
// so protected fields (set via setFieldAsSharedOnly/setFieldAsSourceOnly)
|
||||
// are removed — the API rejects deletes for those with 403.
|
||||
test.beforeAll(async () => {
|
||||
await purgeFieldsByPrefix('Masking');
|
||||
});
|
||||
|
||||
test('MM-68508-1: Full masking round-trip in Simple editor', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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); // UNPLUG: remove to skip masking setup
|
||||
|
||||
// Navigate back to the policy editor — masking now applies on load
|
||||
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()) {
|
||||
await expect(testRulesBtn).toBeDisabled();
|
||||
}
|
||||
// Save-button enabled state is covered functionally by E2E-2 (merge round-trip)
|
||||
// and E2E-10 (held-value addition) — both exercise an actual save and verify the
|
||||
// server preserved hidden values. A pristine "is the button disabled?" check
|
||||
// here would only catch the narrow regression of adding a masking-aware gate to
|
||||
// SaveButton.disabled, which the round-trip tests also cover.
|
||||
} 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).
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
// 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);
|
||||
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. The original test dirtied by removing /
|
||||
// re-adding the visible "Alpha" chip, but masked rows are now fully read-only —
|
||||
// value chips can't be removed and the value selector is disabled. The merge-on-save
|
||||
// invariant we're testing doesn't depend on how the form is dirtied; what matters is
|
||||
// that an actual PUT happens with the masked condition's reduced value set, and the
|
||||
// server re-injects Bravo + Charlie via mergeExpressionWithMaskedValues.
|
||||
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
|
||||
await nameInput.fill(policyName + ' (edited)');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Save — must succeed
|
||||
await saveBtn.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify via API (flag off): 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.
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
const fieldIds: string[] = [];
|
||||
const policyIds: string[] = [];
|
||||
|
||||
try {
|
||||
await enableUserManagedAttributes(adminClient);
|
||||
await enableMaskingFlag(adminClient);
|
||||
|
||||
const fieldName = `MaskingProgram_${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);
|
||||
// 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);
|
||||
|
||||
// Confirm masked state
|
||||
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
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* Shared helpers for the attribute-value-masking E2E suite.
|
||||
*
|
||||
* DB helpers live in masking_db_setup.ts; UI/API helpers shared across the
|
||||
* masking spec files live here.
|
||||
*/
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
|
||||
import {deleteFieldFromDB} from './masking_db_setup';
|
||||
|
||||
/** Enable AttributeValueMasking feature flag */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Disable AttributeValueMasking feature flag */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain text CPA field and return its ID.
|
||||
* Uses a caller-supplied unique name so each test owns its own field.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multiselect CPA field with the given options and return its ID.
|
||||
*/
|
||||
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 {
|
||||
// API failed (e.g. 403 for protected fields) — fall back to DB delete.
|
||||
try {
|
||||
await deleteFieldFromDB(fieldId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a membership policy by ID. Best-effort — never throws.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an attribute value for a user via the admin client.
|
||||
*/
|
||||
export async function setUserAttribute(client: Client4, userId: string, fieldId: string, value: string): Promise<void> {
|
||||
await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a role has a specific permission, adding it if missing.
|
||||
*
|
||||
* Why: the server's stored team_admin / channel_admin roles in this test
|
||||
* environment may be missing the access-rules permissions even though they're
|
||||
* in the model defaults (no migration run, or pre-permission seed). Reads the
|
||||
* current permissions and only PATCHes when the permission is absent.
|
||||
*/
|
||||
export async function ensureRoleHasPermission(client: Client4, roleName: string, permissionId: string): Promise<void> {
|
||||
const role = await client.getRoleByName(roleName);
|
||||
if (role.permissions.includes(permissionId)) {
|
||||
return;
|
||||
}
|
||||
await client.patchRole(role.id, {permissions: [...role.permissions, permissionId]});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
// Fill policy name
|
||||
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
|
||||
await nameInput.waitFor({state: 'visible', timeout: 10000});
|
||||
await nameInput.fill(name);
|
||||
|
||||
// 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);
|
||||
|
||||
// Type CEL expression into the Monaco editor
|
||||
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);
|
||||
|
||||
// Save — no channels so no "Apply Policy" confirmation modal. Capture the
|
||||
// PUT response: saving redirects to the list view, so the URL no longer
|
||||
// carries the policy id. The API response body always has it.
|
||||
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(),
|
||||
);
|
||||
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.
|
||||
*
|
||||
* Many tests create accumulating `MaskingPolicy <rand>` rows during a single
|
||||
* run, so the target row is often beyond the first page. We rely on the search
|
||||
* box to filter, and explicitly wait for the search request to land before
|
||||
* looking for the row — otherwise we race the network and time out on a stale
|
||||
* page.
|
||||
*/
|
||||
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});
|
||||
|
||||
// Wait for the search response triggered by typing the policy name.
|
||||
// The list uses POST /access_control_policies/search with the term in the body,
|
||||
// so we match by URL only and ignore the query payload.
|
||||
const searchResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v4\/access_control_policies\/search$/.test(resp.url()) &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.ok(),
|
||||
);
|
||||
await searchInput.fill(policyName);
|
||||
await searchResponse.catch(() => {
|
||||
// List components debounce; some renders may not fire a fresh request if
|
||||
// the cached result already matches. Fall back to a short settle.
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the policy expression from the server. When the masking flag is ON,
|
||||
* any value the caller does not hold is replaced with the masked-token
|
||||
* sentinel (e.g. "--------") in the returned expression.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for policies and return the first match's first rule expression.
|
||||
*/
|
||||
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}`
|
||||
* — the previous regex captured `edit_policy` (the literal path segment) instead of
|
||||
* the actual id, so getRawPolicyExpression silently fetched against a non-existent
|
||||
* id and returned empty data, masking real test failures.
|
||||
*/
|
||||
export async function getPolicyIdFromURL(page: Page): Promise<string> {
|
||||
const url = page.url();
|
||||
// Match `/edit_policy/<id>` first; fall back to `/membership_policies/<id>` for
|
||||
// older route shapes if the route is ever simplified.
|
||||
const editMatch = url.match(/edit_policy\/([A-Za-z0-9]+)/);
|
||||
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)}`);
|
||||
}
|
||||
Loading…
Reference in a new issue