Files
07e56271fd Feature mm 64509 team admin abac channels (#36061)
* MM-67592 - be changes for team admin abac channels (#35353)

* MM-67592 - be changes for team admin abac channels

* Revert team-scoped API routes, keep app layer business logic

* move from config to permission; Add cluster-aware LRU cache for policy team scope lookup

* remove unnecessary references to config value

* local/remote cache invalidation consistency for policy scope

* Replace policy scope cache with store-level team scope query

* rename functions and add comments to query

---------

Co-authored-by: Mattermost Build <[email protected]>

* MM 67594 - policies CUD operations to team settings modal channels ABAC (#35590)

* MM-67592 - be changes for team admin abac channels

* Revert team-scoped API routes, keep app layer business logic

* move from config to permission; Add cluster-aware LRU cache for policy team scope lookup

* remove unnecessary references to config value

* local/remote cache invalidation consistency for policy scope

* Replace policy scope cache with store-level team scope query

* format files correctly

* fix mock expectations for store-query approach in tests

* rename functions and add comments to query

* revert error ids to original to prevent break tests

* adjust translations

* MM-67669 - add tab to team settings modal and basic listing

* adjust tests and fix linter

* use existing search api logic

* fix style and adjust flaky test to clean up and restore orinals

* address ai corabbit feedback and fix linter

* fix unit tests

* MM-67592 - be changes for team admin abac channels (#35353)

* MM-67592 - be changes for team admin abac channels

* fix linter

* fix ts linter for playwright

* Revert team-scoped API routes, keep app layer business logic

* move from config to permission; Add cluster-aware LRU cache for policy team scope lookup

* remove unnecessary references to config value

* local/remote cache invalidation consistency for policy scope

* Replace policy scope cache with store-level team scope query

* format files correctly

* fix mock expectations for store-query approach in tests

* rename functions and add comments to query

* revert error ids to original to prevent break tests

* adjust translations

---------

Co-authored-by: Mattermost Build <[email protected]>

* MM-67594 - support cud operations for team abac BE changes

* create the team settings policy edit section, reuse most components, add basic e2e

* move optional refresh policy list button to list component

* temp get team admins cud policies and sync job

* enhance validation and adjust e2e

* Fix testExpression permission; fix pagination of team policies; add isValidId validation

* adjust styles, handling renaming and add permission migrations

* update the permissions names, use the simple confirmation modal, define the delete modal

* fix policy deletion flow

* fix some linter issues and adjust helper tests

* remove delete from list and fix e2e

* code comments clean up

* remove CEL editor for now, clean styles, enhance e2e

* fix linter, adjust unit test

* fix linter and add missing translation

* fix policy deletion ownership and sanitize test expression

* fixed e2e tests

* rollback orphaned policy on failed channel assignment

* enforce channelless check before last_team_id fallback

* enforce channelless guard on assign fallback too

* add translations missing

* add teamId to audit payload when present

* fix refresh button pagination reset

* fix null safety in channel selector loadChannels

* use responsive width cap for team settings modal and adjust header size

* remove redundant raw term from channel search URL, add showRefreshButton prop to PolicyList component

* handle error when stamping last team ID on channelless policy

* replace Props-based ownership with in-memory LRU cache, disable save on zero channels

* make e2e tests more reliable in CI

* test skip if no license valid found

* add childCount guard to cache-hit paths and reduce TTL to 5s

* fix e2e, adjust translation

* address review feedback: flatten permission checks and separate error types

- Flatten nested permission branching in deleteAccessControlPolicy using
  early returns to reduce indentation (review: isacikgoz)
- Validate teamID as input (400) before using it for permission checks (403)
  in testExpression and validateExpressionAgainstRequester handlers
- Remove redundant hasSystemPermission check in searchAccessControlPolicies
  since system_admin role already includes manage_team_access_rules
- Refactor ValidateTeamAdminPolicyOwnership to return (bool, *model.AppError)
  separating "not owned" from "internal error" across all 8 call sites
- Update tests to assert on both return values

* add persistent team scope to access control policies, replace in-memory cache

* fix translation

* fix case-insensitive policy search and sanitize search term input

* make policies tests have a unique name

* decouple scope/scopeID filter from TeamID in policy store

* Fix authZ bypass searchChannelsForAccessControlPolicy by forcing TeamIds to authorized team

* show unsaved changes on navigator back, and list all private channels on load

* filter already applied channels to a policy

* adjust the styles to dark mode; do not show added channels to the policy in the add channels modal

* fix linter

* MM-67967 add sync status footer to team settings (#35729)

* MM-67967 add sync status footer to team settings

* remove magic numbers and strings and polish the code

* fix linter

* fix linter: replace interface{} with any per gofmt rewrite rule

* refine getJobsByType team-scoped filtering and permissions

* fix sync footer stuck in syncing state on job creation error

* fix team-scoped job pagination in getJobsByType

* Fix authZ bypass searchChannelsForAccessControlPolicy by forcing TeamIds to authorized team

* implement ux feedback, change titles font, fix marging and scroll view jump

* MM-68135 - migrate add channels to policy modal to generic modal (#35907)

* MM-67920 unify e2e team settings tests (#35867)

* MM-67920 - extract duplicated policy editor helpers

* remove duplicate team icon test file

* rename Access Control to Membership Policies in e2e

* replace networkidle with explicit element waits

* fix attribute loading issue

---------

---------

Co-authored-by: Mattermost Build <[email protected]>

* fix playwright feedback issues and persist filters to the store layer in the no systemconsole path

* Improve policy scope validation and team admin security checks

* Renamed public channels to "AAA Public Channel %03d" and private ones to "ZZZ Private..." so the 55 public channels now fill the 50-result cap

* fix e2e tests and add new unit tests to improve coverage

* Improve e2e test stability: race condition handling and timeout adjustments

* Improve team-scoped ABAC policies: scope preservation, input validation, shared exclusion

* Add comprehensive ABAC test coverage: team admin ops and security validation to reduce flakyness

* Fix team policy editor back button: preserve navigation intent through Undo

* style: format import statements for better readability

* Enhance access control policy creation for team admins: enforce scope stamping from query parameters to prevent unauthorized team assignments

---------

Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-04-16 00:48:43 +02:00

221 lines
8.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator, Page} from '@playwright/test';
import {expect} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import {newTestPassword} from '@mattermost/playwright-lib';
export async function enableABACConfig(client: Client4) {
await client.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
});
}
export async function ensureDepartmentAttribute(client: Client4) {
let fields: any[] = [];
try {
fields = await (client as any).doFetch(`${client.getBaseRoute()}/custom_profile_attributes/fields`, {
method: 'GET',
});
} catch {
// May not exist yet
}
if (!fields.find((f: any) => f.name === 'Department')) {
try {
await (client as any).doFetch(`${client.getBaseRoute()}/custom_profile_attributes/fields`, {
method: 'POST',
body: JSON.stringify({name: 'Department', type: 'text', attrs: {visibility: 'when_set'}}),
});
} catch {
// Another parallel worker may have created the field between our GET and POST.
// Re-fetch to confirm it now exists before propagating any error.
const recheck: any[] = await (client as any).doFetch(
`${client.getBaseRoute()}/custom_profile_attributes/fields`,
{method: 'GET'},
);
if (!recheck.find((f: any) => f.name === 'Department')) {
throw new Error('Failed to create Department custom profile attribute field');
}
}
}
}
export async function createParentPolicy(client: Client4, name: string) {
return (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies`, {
method: 'put',
body: JSON.stringify({
id: '',
name,
type: 'parent',
version: 'v0.2',
revision: 0,
rules: [{expression: 'true', actions: ['*']}],
}),
});
}
export async function assignChannelsToPolicy(client: Client4, policyId: string, channelIds: string[]) {
const url = `${client.getBaseRoute()}/access_control_policies/${policyId}/assign`;
const response = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${client.getToken()}`},
body: JSON.stringify({channel_ids: channelIds}),
});
if (!response.ok) {
throw new Error(`assignChannelsToPolicy failed: ${response.status}`);
}
}
export async function unassignChannelsFromPolicy(
client: Client4,
policyId: string,
channelIds: string[],
teamId?: string,
) {
const url = `${client.getBaseRoute()}/access_control_policies/${policyId}/unassign`;
const response = await fetch(url, {
method: 'DELETE',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${client.getToken()}`},
body: JSON.stringify({channel_ids: channelIds, ...(teamId && {team_id: teamId})}),
});
if (!response.ok) {
throw new Error(`unassignChannelsFromPolicy failed: ${response.status}`);
}
}
export async function deletePolicy(client: Client4, policyId: string, teamId?: string) {
const teamParam = teamId ? `?team_id=${encodeURIComponent(teamId)}` : '';
const url = `${client.getBaseRoute()}/access_control_policies/${policyId}${teamParam}`;
const response = await fetch(url, {
method: 'DELETE',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${client.getToken()}`},
});
if (!response.ok) {
throw new Error(`deletePolicy failed: ${response.status}`);
}
}
export async function searchPolicies(client: Client4, teamId: string): Promise<any[]> {
const result: any = await (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies/search`, {
method: 'post',
body: JSON.stringify({
term: '',
type: 'parent',
cursor: {id: ''},
limit: 100,
include_children: true,
team_id: teamId,
}),
});
return result.policies || [];
}
export async function setUserAttribute(adminClient: Client4, userId: string, fieldName: string, value: string) {
// Get all fields to find the field ID
const fields: any[] = await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/custom_profile_attributes/fields`,
{
method: 'GET',
},
);
const field = fields.find((f: any) => f.name === fieldName);
if (!field) {
throw new Error(`Field "${fieldName}" not found`);
}
await adminClient.updateUserCustomProfileAttributesValues(userId, {[field.id]: value});
}
export async function createPrivateChannel(client: Client4, teamId: string) {
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
return client.createChannel({team_id: teamId, name: `abac-${id}`, display_name: `ABAC-${id}`, type: 'P'} as any);
}
export async function createGroupConstrainedPrivateChannel(client: Client4, teamId: string) {
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
return client.createChannel({
team_id: teamId,
name: `gc-${id}`,
display_name: `GC-${id}`,
type: 'P',
group_constrained: true,
} as any);
}
export async function createPublicChannel(client: Client4, teamId: string) {
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
return client.createChannel({team_id: teamId, name: `pub-${id}`, display_name: `PUB-${id}`, type: 'O'} as any);
}
export async function createTeamAdmin(adminClient: Client4, teamId: string) {
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
const user = await adminClient.createUser(
{
email: `teamadmin-${id}@sample.mattermost.com`,
username: `teamadmin${id}`,
password: newTestPassword(),
} as any,
'',
'',
);
user.password = newTestPassword();
await adminClient.savePreferences(user.id, [
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
{user_id: user.id, category: 'onboarding', name: 'complete', value: 'true'},
]);
await adminClient.addToTeam(teamId, user.id);
await (adminClient as any).doFetch(`${adminClient.getBaseRoute()}/teams/${teamId}/members/${user.id}/roles`, {
method: 'put',
body: JSON.stringify({roles: 'team_user team_admin'}),
});
return user;
}
/**
* Adds an attribute rule row in the policy editor and fills in a value.
* Handles the auto-opening attribute selector menu.
*/
export async function addAttributeRule(container: Locator, page: Page, value: string, attributeName = 'Department') {
const addAttrBtn = container.getByRole('button', {name: /Add attribute/});
await expect(addAttrBtn).toBeEnabled({timeout: 10000});
await addAttrBtn.click();
// The attribute selector menu auto-opens — select by name, not by position.
// Clicking the first item is unreliable when multiple attributes exist (e.g. Location
// sorts before Department and gets picked instead, causing self-inclusion to fail).
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
await attributeMenu.waitFor({state: 'visible', timeout: 5000});
await attributeMenu.locator('li').filter({hasText: attributeName}).first().click();
// Fill value in the simple input (text-type attribute renders direct input)
const valueInput = container.locator('.values-editor__simple-input').first();
await valueInput.waitFor({state: 'visible', timeout: 10000});
await valueInput.fill(value);
// Blur the input so React commits the onChange before the caller proceeds
await valueInput.press('Tab');
}
/**
* Adds a channel to the policy via the channel selector modal.
*/
export async function addChannelToPolicy(container: Locator, page: Page, channelDisplayName: string) {
await container.getByRole('button', {name: /Add channels/}).click();
const channelModal = page.locator('.channel-selector-modal');
await channelModal.waitFor();
await expect(channelModal.locator('.more-modal__row').first()).toBeVisible({timeout: 10000});
await channelModal.locator('.more-modal__row').filter({hasText: channelDisplayName}).click();
await channelModal.getByRole('button', {name: 'Add'}).click();
// Wait for the modal to fully close before returning — callers must not proceed
// until the channel is committed to form state, otherwise a save click races
// against the React state update and the confirmation modal never appears.
await channelModal.waitFor({state: 'hidden', timeout: 20000});
}