From 5442104289f5a1f7496ece78582c1e8b5ab94014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Andr=C3=A9s=20V=C3=A9lez=20Vidal?= Date: Tue, 19 May 2026 22:00:18 +0200 Subject: [PATCH 1/3] MM-68900 - abac masking add e2e back --- e2e-tests/playwright/package-lock.json | 174 ++ e2e-tests/playwright/package.json | 2 + .../masking/attribute_value_masking.spec.ts | 2333 +++++++++++++++++ .../abac/masking/masking_db_setup.ts | 154 ++ 4 files changed, 2663 insertions(+) create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts diff --git a/e2e-tests/playwright/package-lock.json b/e2e-tests/playwright/package-lock.json index f256a203cdf..52aafdfd3f9 100644 --- a/e2e-tests/playwright/package-lock.json +++ b/e2e-tests/playwright/package-lock.json @@ -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", diff --git a/e2e-tests/playwright/package.json b/e2e-tests/playwright/package.json index 6ebb54a091b..39b763e4eda 100644 --- a/e2e-tests/playwright/package.json +++ b/e2e-tests/playwright/package.json @@ -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" diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts new file mode 100644 index 00000000000..8900cb284a5 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts @@ -0,0 +1,2333 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from '@playwright/test'; +import type {Client4} from '@mattermost/client'; + +import {ChannelsPage, expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib'; + +import { + assignChannelsToPolicy, + createPrivateChannel, + createTeamAdmin, + waitForAttributeViewToInclude, +} from '../../../channels/team_settings/helpers'; +import {enableUserManagedAttributes} from '../support'; + +// PLUG: setFieldAsSharedOnly flips a CPA field to shared_only in the DB (the +// API rejects it without a source_plugin_id). getStoredPolicyRuleExpressions +// reads the raw stored CEL straight from the policy table, bypassing the API +// masking pipeline — the AttributeValueMasking feature flag is loaded at +// server boot and cannot be flipped at runtime, so any API-level fetch returns +// the masked view and can't verify what was actually persisted. +import { + setFieldAsSharedOnly, + setFieldAsSourceOnly, + getStoredPolicyRuleExpressions, + deleteFieldFromDB, + purgeFieldsByPrefix, +} from './masking_db_setup'; + +/** + * Attribute-Value Masking E2E Tests + * + * Validates the attribute-value masking feature: + * - Callers see only values they hold; non-held values appear as masked chips + * - Any caller with masked values in an existing policy cannot save changes (UI + * disables Save; server returns HTTP 403) — no role-based bypass + * - Callers with full visibility (no masked values) can save and are subject to + * self-inclusion validation + * - Non-held values are rejected on the write path via value-hold validation + * - Feature flag gates all masking behaviour + * - Raw CEL is redacted in GET and search API responses + * + * Each test creates its own uniquely-named CPA field and policy, and cleans + * them up in a finally block so no state leaks between tests. + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Enable AttributeValueMasking feature flag */ +async function enableMaskingFlag(client: Client4): Promise { + const config = await client.getConfig(); + config.FeatureFlags = config.FeatureFlags || {}; + (config.FeatureFlags as any).AttributeValueMasking = true; + await client.updateConfig(config); +} + +/** Disable AttributeValueMasking feature flag */ +async function disableMaskingFlag(client: Client4): Promise { + 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. + */ +async function createMaskingTextField(client: Client4, fieldName: string): Promise { + 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. + */ +async function createMaskingMultiselectField(client: Client4, fieldName: string, options: string[]): Promise { + 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. + */ +async function deleteCPAField(client: Client4, fieldId: string): Promise { + 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. + */ +async function deletePolicy(client: Client4, policyId: string): Promise { + 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. + */ +async function setUserAttribute(client: Client4, userId: string, fieldId: string, value: string): Promise { + await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value}); +} + +/** + * Create a membership policy using the Advanced (CEL) editor in the UI. + * Does NOT add channels so there is no "Apply policy" gate to click through. + * Returns the policy ID extracted from the URL after saving. + */ +async function createPolicyWithCEL(page: Page, name: string, celExpression: string): Promise { + 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(), + {timeout: 15000}, + ); + await saveBtn.click(); + const saveResp = await savePromise; + const saved = await saveResp.json(); + await page.waitForLoadState('networkidle'); + + const id = (saved?.id ?? saved?.ID ?? '') as string; + if (!/^[A-Za-z0-9]{26}$/.test(id)) { + throw new Error( + `createPolicyWithCEL: save response did not include a valid policy id (got ${JSON.stringify(id)})`, + ); + } + return id; +} + +/** + * Navigate to the membership-policies list and open the editor for the named policy. + * + * Many tests create accumulating `MaskingPolicy ` 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. + */ +async function openExistingPolicy(page: Page, policyName: string): Promise { + 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(), + {timeout: 15000}, + ); + 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. + */ +async function getRawPolicyExpression(page: Page, policyId: string): Promise { + 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. + */ +async function searchPoliciesExpression(page: Page, term: string): Promise { + 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. + */ +async function getPolicyIdFromURL(page: Page): Promise { + const url = page.url(); + // Match `/edit_policy/` first; fall back to `/membership_policies/` 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)}`); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe('Attribute-Value Masking', () => { + // 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}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 3000})) { + 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). + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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. + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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 + } + }); + + test('MM-68508-4: Self-inclusion failure blocks save (caller has full visibility)', async ({pw}) => { + // Self-inclusion is only checked when the caller holds ALL values in the policy + // (no masked values). If masked values are present the 403 block fires first + // and the Save button is disabled. This test uses a single-value policy so the + // caller has full visibility, then removes their own satisfying value. + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient, team} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 8000}); + + // Reload — Alpha should still be in the stored policy (save was blocked) + await page.reload(); + await page.waitForLoadState('networkidle'); + await openExistingPolicy(page, policyName); + await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible(); + } finally { + for (const id of policyIds) { + try { + await deletePolicy(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + for (const id of fieldIds) { + try { + await deleteCPAField(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + try { + await disableMaskingFlag(adminClient); + } catch {} // eslint-disable-line no-empty + } + }); + + test('MM-68508-5: Non-held value rejected via direct API', async ({pw}) => { + test.setTimeout(60000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 5000}); + + // Test-access-rule button must be disabled in CEL mode with masked values + const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'}); + if (await testRulesBtn.isVisible({timeout: 3000})) { + await expect(testRulesBtn).toBeDisabled(); + } + + // Switch back to Simple mode — masked chip is still present + const simpleBtn = page.getByRole('button', {name: /simple/i}); + if (await simpleBtn.isVisible({timeout: 3000})) { + await simpleBtn.click(); + await page.waitForTimeout(500); + await expect(page.locator('.select__multi-value--masked')).toBeVisible(); + } + } finally { + for (const id of policyIds) { + try { + await deletePolicy(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + for (const id of fieldIds) { + try { + await deleteCPAField(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + try { + await disableMaskingFlag(adminClient); + } catch {} // eslint-disable-line no-empty + } + }); + + test('MM-68508-7: Caller holding all policy values sees them all unmasked', async ({pw}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 3000})) { + await expect(testRulesBtn).not.toBeDisabled(); + } + + // CEL mode is editable (no read-only) + const advancedBtn = page.getByRole('button', {name: /advanced/i}); + if (await advancedBtn.isVisible({timeout: 3000})) { + await advancedBtn.click(); + await page.waitForTimeout(1000); + const monacoEditor = page.locator('.monaco-editor').first(); + if (await monacoEditor.isVisible({timeout: 3000})) { + const ariaReadOnly = await monacoEditor.getAttribute('aria-readonly'); + expect(ariaReadOnly).not.toBe('true'); + } + } + } finally { + for (const id of policyIds) { + try { + await deletePolicy(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + for (const id of fieldIds) { + try { + await deleteCPAField(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + try { + await disableMaskingFlag(adminClient); + } catch {} // eslint-disable-line no-empty + } + }); + + test('MM-68508-8: New policy creation has no masking', async ({pw}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 3000})) && !(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. + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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 + } + }); + + test('MM-68508-11: Text field masking with single-value operator (value not held)', async ({pw}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + // adminUser holds "Building 1"; policy value is "Building 7" (not held) + const fieldName = `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({timeout: 5000}); + } finally { + for (const id of policyIds) { + try { + await deletePolicy(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + for (const id of fieldIds) { + try { + await deleteCPAField(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + try { + await disableMaskingFlag(adminClient); + } catch {} // eslint-disable-line no-empty + } + }); + + test('MM-68508-12: GET /policies/{id} does not leak raw CEL when values are masked', async ({pw}) => { + test.setTimeout(60000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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}) => { + test.setTimeout(60000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 5000}); + + // Open clean policy — warning banner must NOT be present + await openExistingPolicy(page, cleanPolicyName); + await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible(); + } finally { + for (const id of policyIds) { + try { + await deletePolicy(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + for (const id of fieldIds) { + try { + await deleteCPAField(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + try { + await disableMaskingFlag(adminClient); + } catch {} // eslint-disable-line no-empty + } + }); + + test('MM-68508-15: Delete button is disabled on masked policies; clean policies open the standard confirmation modal', async ({ + pw, + }) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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 + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 5000})) { + 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. + test.setTimeout(150000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const programFieldName = `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({timeout: 3000})) { + 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_.id_`), 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. + test.setTimeout(150000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient, team} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 5000})) { + 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({timeout: 3000})) { + 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({timeout: 2000})) { + 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 + } + }); + + test('MM-68508-19: Mode toggle Simple → Advanced → Simple preserves all masked-row restrictions', async ({pw}) => { + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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({timeout: 2000})) { + await expect(valueSelector).toBeDisabled(); + } + } finally { + for (const id of policyIds) { + try { + await deletePolicy(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + for (const id of fieldIds) { + try { + await deleteCPAField(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + try { + await disableMaskingFlag(adminClient); + } catch {} // eslint-disable-line no-empty + } + }); + + test('MM-68508-20: Team admin (non-sysadmin) sees the same masking as a system admin in team settings', async ({ + pw, + }) => { + // Role-neutrality across roles: a delegated team admin (granted + // PermissionManageTeamAccessRules by their team_admin role, but NOT + // PermissionManageSystem) must see masking in the team-settings access + // policy editor. The masked-values guard MUST apply at this surface too: + // controls locked, Delete disabled, server 403 on direct DELETE. + test.setTimeout(180000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient, team} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const teamAdmin = await createTeamAdmin(adminClient, team.id); + + const fieldName = `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({timeout: 10000}); + const searchResponse = page.waitForResponse( + (resp) => + /\/api\/v4\/access_control_policies\/search$/.test(resp.url()) && + resp.request().method() === 'POST' && + Boolean(resp.request().postData()?.includes(policyName)) && + resp.ok(), + {timeout: 15000}, + ); + await searchInput.fill(policyName); + await searchResponse.catch(() => { + // Debounced search can occasionally settle from cached data; the + // row assertion below is the source of truth. + }); + await page.waitForLoadState('networkidle'); + + const policyRow = teamSettings.container.getByText(policyName).first(); + await expect(policyRow).toBeVisible({timeout: 10000}); + await policyRow.click(); + await page.waitForTimeout(500); + + // Masking surfaces in the team-policy editor exactly as in the + // system console — 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({timeout: 5000}); + 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. + test.setTimeout(180000); + 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); + + // 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. + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const fieldName = `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. + test.setTimeout(120000); + await pw.skipIfNoLicense(); + + const {adminUser, adminClient, team} = await pw.initSetup(); + const fieldIds: string[] = []; + const policyIds: string[] = []; + + try { + await enableUserManagedAttributes(adminClient); + await enableMaskingFlag(adminClient); + + const id = pw.random.id(); + const publicFieldName = `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({timeout: 5000}); + + // shared_only (value "Beta") and source_only (value "Gamma") must NOT appear. + await expect(alertContainer.getByText(/:\s*Beta/)).not.toBeVisible(); + await expect(alertContainer.getByText(/:\s*Gamma/)).not.toBeVisible(); + } finally { + for (const id of policyIds) { + try { + await deletePolicy(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + for (const id of fieldIds) { + try { + await deleteCPAField(adminClient, id); + } catch {} // eslint-disable-line no-empty + } + try { + await disableMaskingFlag(adminClient); + } catch {} // eslint-disable-line no-empty + } + }); +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts new file mode 100644 index 00000000000..89b89792a20 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts @@ -0,0 +1,154 @@ +// 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/mattermost_test?sslmode=disable'; + +function resolveDbUrl(): string { + return process.env.MM_TEST_DB_URL ?? DEFAULT_DB_URL; +} + +async function runQuery(sql: string, params: unknown[] = []): Promise { + 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 { + 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 { + await setFieldAccessMode(fieldId, 'source_only'); +} + +/** + * Set the field back to public (removes the access_mode attr). + */ +export async function setFieldAsPublic(fieldId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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], + ); +} From 405bcbaf76d789493cd77c95598d3ce370d79083 Mon Sep 17 00:00:00 2001 From: Saturnino Abril <5334504+saturninoabril@users.noreply.github.com> Date: Wed, 20 May 2026 12:47:20 +0800 Subject: [PATCH 2/3] Add attribute value masking feature flag and update test database URL --- e2e-tests/.ci/server.generate.sh | 2 ++ .../functional/system_console/abac/masking/masking_db_setup.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e-tests/.ci/server.generate.sh b/e2e-tests/.ci/server.generate.sh index d53b339aa78..ce8399b9bae 100755 --- a/e2e-tests/.ci/server.generate.sh +++ b/e2e-tests/.ci/server.generate.sh @@ -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" diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts index 89b89792a20..65cd9eda945 100644 --- a/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts @@ -21,7 +21,7 @@ import {Client} from 'pg'; -const DEFAULT_DB_URL = 'postgres://mmuser:mostest@localhost/mattermost_test?sslmode=disable'; +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; From b66675412b0837b20a7336abd9ff2df533d8b794 Mon Sep 17 00:00:00 2001 From: Saturnino Abril <5334504+saturninoabril@users.noreply.github.com> Date: Wed, 20 May 2026 13:43:43 +0800 Subject: [PATCH 3/3] Add end-to-end tests for Attribute-Value Masking functionality - Implement save validation tests for self-inclusion failure, direct API rejection of non-held values, and read-only CEL editor state when masked values are present. - Create simple editor tests to validate masked-chip UI, dirty-form save behavior, and row-remove lockdown for masked rows. - Introduce shared helper functions for managing masking-related operations, including enabling/disabling feature flags, creating and deleting custom profile attributes, and handling policy creation and management. --- .../masking/api_redaction_and_banner.spec.ts | 288 ++ .../masking/attribute_value_masking.spec.ts | 2333 ----------------- .../abac/masking/delete_behaviors.spec.ts | 438 ++++ .../abac/masking/editor_states.spec.ts | 319 +++ .../abac/masking/masking_db_setup.ts | 3 +- .../abac/masking/modes_and_role_views.spec.ts | 586 +++++ .../abac/masking/save_validation.spec.ts | 284 ++ .../abac/masking/simple_editor.spec.ts | 263 ++ .../system_console/abac/masking/support.ts | 294 +++ 9 files changed, 2474 insertions(+), 2334 deletions(-) create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/api_redaction_and_banner.spec.ts delete mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/delete_behaviors.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/editor_states.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/modes_and_role_views.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/save_validation.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/simple_editor.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/system_console/abac/masking/support.ts diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/api_redaction_and_banner.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/api_redaction_and_banner.spec.ts new file mode 100644 index 00000000000..4711ebbd503 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/api_redaction_and_banner.spec.ts @@ -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 + } +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts deleted file mode 100644 index 8900cb284a5..00000000000 --- a/e2e-tests/playwright/specs/functional/system_console/abac/masking/attribute_value_masking.spec.ts +++ /dev/null @@ -1,2333 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {Page} from '@playwright/test'; -import type {Client4} from '@mattermost/client'; - -import {ChannelsPage, expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib'; - -import { - assignChannelsToPolicy, - createPrivateChannel, - createTeamAdmin, - waitForAttributeViewToInclude, -} from '../../../channels/team_settings/helpers'; -import {enableUserManagedAttributes} from '../support'; - -// PLUG: setFieldAsSharedOnly flips a CPA field to shared_only in the DB (the -// API rejects it without a source_plugin_id). getStoredPolicyRuleExpressions -// reads the raw stored CEL straight from the policy table, bypassing the API -// masking pipeline — the AttributeValueMasking feature flag is loaded at -// server boot and cannot be flipped at runtime, so any API-level fetch returns -// the masked view and can't verify what was actually persisted. -import { - setFieldAsSharedOnly, - setFieldAsSourceOnly, - getStoredPolicyRuleExpressions, - deleteFieldFromDB, - purgeFieldsByPrefix, -} from './masking_db_setup'; - -/** - * Attribute-Value Masking E2E Tests - * - * Validates the attribute-value masking feature: - * - Callers see only values they hold; non-held values appear as masked chips - * - Any caller with masked values in an existing policy cannot save changes (UI - * disables Save; server returns HTTP 403) — no role-based bypass - * - Callers with full visibility (no masked values) can save and are subject to - * self-inclusion validation - * - Non-held values are rejected on the write path via value-hold validation - * - Feature flag gates all masking behaviour - * - Raw CEL is redacted in GET and search API responses - * - * Each test creates its own uniquely-named CPA field and policy, and cleans - * them up in a finally block so no state leaks between tests. - */ - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Enable AttributeValueMasking feature flag */ -async function enableMaskingFlag(client: Client4): Promise { - const config = await client.getConfig(); - config.FeatureFlags = config.FeatureFlags || {}; - (config.FeatureFlags as any).AttributeValueMasking = true; - await client.updateConfig(config); -} - -/** Disable AttributeValueMasking feature flag */ -async function disableMaskingFlag(client: Client4): Promise { - 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. - */ -async function createMaskingTextField(client: Client4, fieldName: string): Promise { - 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. - */ -async function createMaskingMultiselectField(client: Client4, fieldName: string, options: string[]): Promise { - 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. - */ -async function deleteCPAField(client: Client4, fieldId: string): Promise { - 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. - */ -async function deletePolicy(client: Client4, policyId: string): Promise { - 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. - */ -async function setUserAttribute(client: Client4, userId: string, fieldId: string, value: string): Promise { - await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value}); -} - -/** - * Create a membership policy using the Advanced (CEL) editor in the UI. - * Does NOT add channels so there is no "Apply policy" gate to click through. - * Returns the policy ID extracted from the URL after saving. - */ -async function createPolicyWithCEL(page: Page, name: string, celExpression: string): Promise { - 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(), - {timeout: 15000}, - ); - await saveBtn.click(); - const saveResp = await savePromise; - const saved = await saveResp.json(); - await page.waitForLoadState('networkidle'); - - const id = (saved?.id ?? saved?.ID ?? '') as string; - if (!/^[A-Za-z0-9]{26}$/.test(id)) { - throw new Error( - `createPolicyWithCEL: save response did not include a valid policy id (got ${JSON.stringify(id)})`, - ); - } - return id; -} - -/** - * Navigate to the membership-policies list and open the editor for the named policy. - * - * Many tests create accumulating `MaskingPolicy ` 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. - */ -async function openExistingPolicy(page: Page, policyName: string): Promise { - 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(), - {timeout: 15000}, - ); - 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. - */ -async function getRawPolicyExpression(page: Page, policyId: string): Promise { - 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. - */ -async function searchPoliciesExpression(page: Page, term: string): Promise { - 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. - */ -async function getPolicyIdFromURL(page: Page): Promise { - const url = page.url(); - // Match `/edit_policy/` first; fall back to `/membership_policies/` 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)}`); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -test.describe('Attribute-Value Masking', () => { - // 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}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 3000})) { - 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). - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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. - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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 - } - }); - - test('MM-68508-4: Self-inclusion failure blocks save (caller has full visibility)', async ({pw}) => { - // Self-inclusion is only checked when the caller holds ALL values in the policy - // (no masked values). If masked values are present the 403 block fires first - // and the Save button is disabled. This test uses a single-value policy so the - // caller has full visibility, then removes their own satisfying value. - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient, team} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 8000}); - - // Reload — Alpha should still be in the stored policy (save was blocked) - await page.reload(); - await page.waitForLoadState('networkidle'); - await openExistingPolicy(page, policyName); - await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible(); - } finally { - for (const id of policyIds) { - try { - await deletePolicy(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - for (const id of fieldIds) { - try { - await deleteCPAField(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - try { - await disableMaskingFlag(adminClient); - } catch {} // eslint-disable-line no-empty - } - }); - - test('MM-68508-5: Non-held value rejected via direct API', async ({pw}) => { - test.setTimeout(60000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 5000}); - - // Test-access-rule button must be disabled in CEL mode with masked values - const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'}); - if (await testRulesBtn.isVisible({timeout: 3000})) { - await expect(testRulesBtn).toBeDisabled(); - } - - // Switch back to Simple mode — masked chip is still present - const simpleBtn = page.getByRole('button', {name: /simple/i}); - if (await simpleBtn.isVisible({timeout: 3000})) { - await simpleBtn.click(); - await page.waitForTimeout(500); - await expect(page.locator('.select__multi-value--masked')).toBeVisible(); - } - } finally { - for (const id of policyIds) { - try { - await deletePolicy(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - for (const id of fieldIds) { - try { - await deleteCPAField(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - try { - await disableMaskingFlag(adminClient); - } catch {} // eslint-disable-line no-empty - } - }); - - test('MM-68508-7: Caller holding all policy values sees them all unmasked', async ({pw}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 3000})) { - await expect(testRulesBtn).not.toBeDisabled(); - } - - // CEL mode is editable (no read-only) - const advancedBtn = page.getByRole('button', {name: /advanced/i}); - if (await advancedBtn.isVisible({timeout: 3000})) { - await advancedBtn.click(); - await page.waitForTimeout(1000); - const monacoEditor = page.locator('.monaco-editor').first(); - if (await monacoEditor.isVisible({timeout: 3000})) { - const ariaReadOnly = await monacoEditor.getAttribute('aria-readonly'); - expect(ariaReadOnly).not.toBe('true'); - } - } - } finally { - for (const id of policyIds) { - try { - await deletePolicy(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - for (const id of fieldIds) { - try { - await deleteCPAField(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - try { - await disableMaskingFlag(adminClient); - } catch {} // eslint-disable-line no-empty - } - }); - - test('MM-68508-8: New policy creation has no masking', async ({pw}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 3000})) && !(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. - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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 - } - }); - - test('MM-68508-11: Text field masking with single-value operator (value not held)', async ({pw}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - // adminUser holds "Building 1"; policy value is "Building 7" (not held) - const fieldName = `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({timeout: 5000}); - } finally { - for (const id of policyIds) { - try { - await deletePolicy(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - for (const id of fieldIds) { - try { - await deleteCPAField(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - try { - await disableMaskingFlag(adminClient); - } catch {} // eslint-disable-line no-empty - } - }); - - test('MM-68508-12: GET /policies/{id} does not leak raw CEL when values are masked', async ({pw}) => { - test.setTimeout(60000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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}) => { - test.setTimeout(60000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 5000}); - - // Open clean policy — warning banner must NOT be present - await openExistingPolicy(page, cleanPolicyName); - await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible(); - } finally { - for (const id of policyIds) { - try { - await deletePolicy(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - for (const id of fieldIds) { - try { - await deleteCPAField(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - try { - await disableMaskingFlag(adminClient); - } catch {} // eslint-disable-line no-empty - } - }); - - test('MM-68508-15: Delete button is disabled on masked policies; clean policies open the standard confirmation modal', async ({ - pw, - }) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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 - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 5000})) { - 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. - test.setTimeout(150000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const programFieldName = `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({timeout: 3000})) { - 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_.id_`), 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. - test.setTimeout(150000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient, team} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 5000})) { - 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({timeout: 3000})) { - 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({timeout: 2000})) { - 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 - } - }); - - test('MM-68508-19: Mode toggle Simple → Advanced → Simple preserves all masked-row restrictions', async ({pw}) => { - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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({timeout: 2000})) { - await expect(valueSelector).toBeDisabled(); - } - } finally { - for (const id of policyIds) { - try { - await deletePolicy(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - for (const id of fieldIds) { - try { - await deleteCPAField(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - try { - await disableMaskingFlag(adminClient); - } catch {} // eslint-disable-line no-empty - } - }); - - test('MM-68508-20: Team admin (non-sysadmin) sees the same masking as a system admin in team settings', async ({ - pw, - }) => { - // Role-neutrality across roles: a delegated team admin (granted - // PermissionManageTeamAccessRules by their team_admin role, but NOT - // PermissionManageSystem) must see masking in the team-settings access - // policy editor. The masked-values guard MUST apply at this surface too: - // controls locked, Delete disabled, server 403 on direct DELETE. - test.setTimeout(180000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient, team} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const teamAdmin = await createTeamAdmin(adminClient, team.id); - - const fieldName = `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({timeout: 10000}); - const searchResponse = page.waitForResponse( - (resp) => - /\/api\/v4\/access_control_policies\/search$/.test(resp.url()) && - resp.request().method() === 'POST' && - Boolean(resp.request().postData()?.includes(policyName)) && - resp.ok(), - {timeout: 15000}, - ); - await searchInput.fill(policyName); - await searchResponse.catch(() => { - // Debounced search can occasionally settle from cached data; the - // row assertion below is the source of truth. - }); - await page.waitForLoadState('networkidle'); - - const policyRow = teamSettings.container.getByText(policyName).first(); - await expect(policyRow).toBeVisible({timeout: 10000}); - await policyRow.click(); - await page.waitForTimeout(500); - - // Masking surfaces in the team-policy editor exactly as in the - // system console — 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({timeout: 5000}); - 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. - test.setTimeout(180000); - 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); - - // 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. - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const fieldName = `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. - test.setTimeout(120000); - await pw.skipIfNoLicense(); - - const {adminUser, adminClient, team} = await pw.initSetup(); - const fieldIds: string[] = []; - const policyIds: string[] = []; - - try { - await enableUserManagedAttributes(adminClient); - await enableMaskingFlag(adminClient); - - const id = pw.random.id(); - const publicFieldName = `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({timeout: 5000}); - - // shared_only (value "Beta") and source_only (value "Gamma") must NOT appear. - await expect(alertContainer.getByText(/:\s*Beta/)).not.toBeVisible(); - await expect(alertContainer.getByText(/:\s*Gamma/)).not.toBeVisible(); - } finally { - for (const id of policyIds) { - try { - await deletePolicy(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - for (const id of fieldIds) { - try { - await deleteCPAField(adminClient, id); - } catch {} // eslint-disable-line no-empty - } - try { - await disableMaskingFlag(adminClient); - } catch {} // eslint-disable-line no-empty - } - }); -}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/delete_behaviors.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/delete_behaviors.spec.ts new file mode 100644 index 00000000000..5faf73fd7d4 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/delete_behaviors.spec.ts @@ -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_.id_`), 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 + } +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/editor_states.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/editor_states.spec.ts new file mode 100644 index 00000000000..f8daa0563eb --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/editor_states.spec.ts @@ -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 + } +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts index 65cd9eda945..310111e1550 100644 --- a/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/masking_db_setup.ts @@ -21,7 +21,8 @@ import {Client} from 'pg'; -const DEFAULT_DB_URL = 'postgres://mmuser:mostest@localhost:5432/mattermost_test?sslmode=disable&connect_timeout=10&binary_parameters=yes'; +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; diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/modes_and_role_views.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/modes_and_role_views.spec.ts new file mode 100644 index 00000000000..540bd8d5425 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/modes_and_role_views.spec.ts @@ -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 + } +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/save_validation.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/save_validation.spec.ts new file mode 100644 index 00000000000..3682402ac9c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/save_validation.spec.ts @@ -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 + } +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/simple_editor.spec.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/simple_editor.spec.ts new file mode 100644 index 00000000000..012e0740852 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/simple_editor.spec.ts @@ -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 + } +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/abac/masking/support.ts b/e2e-tests/playwright/specs/functional/system_console/abac/masking/support.ts new file mode 100644 index 00000000000..f43dee906b9 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/abac/masking/support.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 ` 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 { + 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 { + 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 { + 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 { + const url = page.url(); + // Match `/edit_policy/` first; fall back to `/membership_policies/` 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)}`); +}