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

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

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Add Members to Team modal (System Console) blocks non-qualifying candidates in strict mode
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies} from './helpers';
test.describe('ABAC - Add members to team (System Console)', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-68846-T7 PRIVATE governed team blocks non-qualifying candidates inline', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create private team with Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 (Engineering) and mkt1 (Marketing) — not added to team yet
const createUser = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
first_name: dept,
last_name: label,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// # Log in as system admin and navigate to the per-team console page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(`/admin_console/user_management/teams/${team.id}`);
// # Click "Add Members" button
await page.locator('#addTeamMembers').click();
const addModal = page.locator('#addUsersToTeamModal');
await expect(addModal).toBeVisible({timeout: 10000});
// MultiSelect uses React-Select which renders the input as a combobox role, not
// a plain <input placeholder="...">. Use getByRole to match the accessible name.
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
await expect(searchInput).toBeVisible({timeout: 10000});
// # Search for eng1 — qualifying candidate
await searchInput.pressSequentially(eng1.username.slice(0, 8));
const eng1Row = addModal.locator('.more-modal__row', {hasText: eng1.username});
await expect(eng1Row).toBeVisible({timeout: 10000});
// * eng1's row is NOT disabled
await expect(eng1Row).not.toHaveClass(/more-modal__row--disabled/);
await expect(eng1Row.locator('.more-modal__error')).not.toBeVisible();
// # Search for mkt1 — non-qualifying candidate
await searchInput.clear();
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
await expect(mkt1Row).toBeVisible({timeout: 10000});
// * mkt1's row is disabled with the blocking message
await expect(mkt1Row).toHaveClass(/more-modal__row--disabled/);
await expect(mkt1Row.locator('.more-modal__error')).toHaveText('Does not meet membership requirements');
});
test('MM-68846-T8 PUBLIC governed team does NOT block candidates (advisory)', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create public team with Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 and mkt1
const createUser = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
first_name: dept,
last_name: label,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Advisory policy (public team)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// # Log in as system admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(`/admin_console/user_management/teams/${team.id}`);
// # Click "Add Members" button
await page.locator('#addTeamMembers').click();
const addModal = page.locator('#addUsersToTeamModal');
await expect(addModal).toBeVisible({timeout: 10000});
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
await expect(searchInput).toBeVisible({timeout: 10000});
// # Search for mkt1 — non-qualifying but advisory = no block
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
await expect(mkt1Row).toBeVisible({timeout: 10000});
// * Row is NOT disabled
await expect(mkt1Row).not.toHaveClass(/more-modal__row--disabled/);
await expect(mkt1Row.locator('.more-modal__error')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import {expect, test} from '@mattermost/playwright-lib';
import {
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValuesForUser,
} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {createUserForABAC, ensureUserAttributes} from '../support';
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
/**
* ABAC — Team directory hiding
*
* Enforcement is mode-dependent. A private (non-open-invite) governed team is
* strict: it is surfaced into the directory only for non-members who satisfy the
* policy and omitted for everyone else. A public (open-invite) governed team is
* advisory: it stays visible to all. Asserted at the API layer (each user's own
* client) so it proves server-side filtering, not just DOM hiding — exercising the
* full api4 → app filter → store-listing chain, including the governed-team
* listing widening that surfaces private governed teams to qualifying users.
*/
test.describe('ABAC - Team directory hiding', {tag: ['@abac', '@team_membership']}, () => {
// Each test cleans up the resources it creates, even on failure. initSetup's
// own team/users are owned by the framework and left alone.
let cleanupClient: Client4 | undefined;
const createdTeamIds: string[] = [];
const createdUserIds: string[] = [];
const createdPolicyIds: string[] = [];
test.afterEach(async () => {
const client = cleanupClient;
cleanupClient = undefined;
const policyIds = createdPolicyIds.splice(0);
const teamIds = createdTeamIds.splice(0);
const userIds = createdUserIds.splice(0);
if (!client) {
return;
}
const base = client.getBaseRoute();
const headers = {Authorization: `Bearer ${client.getToken()}`};
for (const id of policyIds) {
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
}
for (const id of teamIds) {
await client.deleteTeam(id).catch(() => {});
}
// Hard user deletion requires server config; deactivating is the API-safe cleanup.
for (const id of userIds) {
await client.updateUserActive(id, false).catch(() => {});
}
});
test('MM-68846-T1 - omits a policy-governed team from the directory for non-qualifying users', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Two non-members: one qualifies (Engineering), one does not (Sales).
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(qualUser.id, nonQualUser.id);
// A private (non-open-invite) governed team is strict: ABAC surfaces it into
// the directory only for users who qualify and hides it from everyone else.
// The policy never mutates allow_open_invite.
await adminClient.patchTeam({id: team.id, allow_open_invite: false} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Directory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [team.id]);
// Control: a private team with NO policy. The listing widening must surface
// only governed teams, so this one stays invisible to both users regardless
// of qualification — guarding against the OR clause over-widening.
const ungoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Ungoverned ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(ungoverned.id);
// ABAC evaluates against a materialized view that refreshes on an interval;
// wait until the qualifying user is visible to the evaluator before asserting.
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
// The child policy row is written to the master DB; the directory query
// reads from a read replica. Poll policy_enforced until the replica has
// caught up so the EXISTS subquery in GetAllPage widens correctly.
await expect
.poll(async () => (await adminClient.getTeam(team.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'team should show policy_enforced=true before asserting directory visibility',
})
.toBe(true);
// API layer: proves the server filters the listing, not just the DOM.
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
// Paginate to collect all visible teams — busy CI environments accumulate
// many public teams across test runs, so a single page-0 fetch misses teams
// that land beyond per_page=200. Use batch.length === 0 (not < per_page) as
// the stop condition: the server applies FilterNonQualifyingTeamsForUser
// after fetching a full DB page, so a batch can legitimately come back
// smaller than per_page while more pages still exist in the DB.
const allTeamIds = async (client: Client4): Promise<string[]> => {
const ids: string[] = [];
let page = 0;
while (true) {
const batch = (await client.getTeams(page, 200)) as Team[];
if (batch.length === 0) {
break;
}
ids.push(...batch.map((t) => t.id));
page++;
}
return ids;
};
const qualIds = await allTeamIds(qualClient);
const nonQualIds = await allTeamIds(nonQualClient);
expect(qualIds).toContain(team.id);
expect(nonQualIds).not.toContain(team.id);
// The ungoverned private team is never surfaced, qualified or not.
expect(qualIds).not.toContain(ungoverned.id);
expect(nonQualIds).not.toContain(ungoverned.id);
// The boundary is asserted at the API layer. The /select_team directory page
// additionally filters joinable teams to open-invite teams client-side, so it
// never renders a private governed team regardless of the server decision —
// surfacing those in the directory UI is separate webapp work.
});
test('MM-68846-T2 - hides a governed team from a non-qualifying system admin in the directory while the management listing stays complete', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Give the admin a present-but-non-matching Department so the policy
// deterministically denies them. A missing attribute is not the same as a
// wrong one — the expression must evaluate to false, not error on an absent
// key — so the admin gets "Sales" against an "Engineering" policy.
await setupCustomProfileAttributeValuesForUser(
adminClient,
[{name: 'Department', value: 'Sales', type: 'text'}],
attributeFieldsMap,
adminUser.id,
);
// A private (non-open-invite), policy-governed team the admin is NOT a member
// of: create it as admin (the creator is auto-joined), then remove the admin
// so the directory treats them as a non-member non-qualifying browser. Private
// → strict, so for_directory hiding applies to the admin too.
const governed = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Governed ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(governed.id);
await adminClient.removeFromTeam(governed.id, adminUser.id);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Admin Directory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [governed.id]);
const base = adminClient.getBaseRoute();
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
const listDirectoryTeamIds = async (forDirectory: boolean): Promise<string[]> => {
const url = `${base}/teams?page=0&per_page=200${forDirectory ? '&for_directory=true' : ''}`;
const resp = await fetch(url, {headers});
const teams = (await resp.json()) as Team[];
return teams.map((t) => t.id);
};
// The assignment becomes visible through a materialized attribute view and a
// read replica, so poll the directory listing until the governed team drops
// out for the admin (for_directory) rather than asserting on the first read.
// A transient fetch error counts as "not ready yet" so the poll keeps going.
await expect
.poll(
async () => {
try {
return (await listDirectoryTeamIds(true)).includes(governed.id);
} catch {
return true;
}
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'governed team should be hidden from the admin directory listing',
},
)
.toBe(false);
// Without for_directory the admin stays exempt, so the System Console listing
// is still complete.
expect(await listDirectoryTeamIds(false)).toContain(governed.id);
// UI: with the backend confirmed above, the team-selection directory hides it
// for the admin too.
const adminSession = await pw.testBrowser.login(adminUser);
await adminSession.page.goto('/select_team');
await adminSession.page.waitForLoadState('networkidle');
await expect(adminSession.page.getByText(governed.display_name, {exact: false})).toHaveCount(0);
});
test('MM-68846-T3 - keeps a public governed team visible to a non-qualifying user (advisory mode)', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// A user who does NOT satisfy the policy (Sales against an Engineering rule).
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(nonQualUser.id);
// A public (open-invite) governed team: the policy is advisory, so it stays
// in the directory for everyone regardless of qualification — no hiding.
// open-invite is set via patch (the create payload does not persist it).
const publicGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Public Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Advisory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
// Confirm enforcement is actually live before asserting advisory behavior —
// otherwise "visible" is indistinguishable from a plain ungoverned public team
// and a regression to strict-on-public would go undetected.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// Advisory: despite being governed, the public team stays visible to the
// non-qualifying user — the policy never hides it.
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
expect(nonQualTeams.map((t) => t.id)).toContain(publicGoverned.id);
});
test('MM-68846-T4 - join gate forks on privacy mode: advisory public admits, strict private denies', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Present-but-wrong attribute (Sales against an Engineering rule) so the PDP
// evaluates the user to false rather than erroring on a missing key.
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(nonQualUser.id);
// The admin drives the readiness poll below; give them the same present-but-
// wrong attribute so the private team deterministically drops from their
// for_directory listing once enforcement is live.
await setupCustomProfileAttributeValuesForUser(
adminClient,
[{name: 'Department', value: 'Sales', type: 'text'}],
attributeFieldsMap,
adminUser.id,
);
const expression = 'user.attributes.Department == "Engineering"';
const newGovernedTeam = async (open: boolean) => {
const t = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(t.id);
if (open) {
// open-invite is set via patch (the create payload does not persist it).
await adminClient.patchTeam({id: t.id, allow_open_invite: true} as any);
}
// The creator is auto-joined, and members always retain directory
// visibility — remove the admin so they read as a non-member browser.
await adminClient.removeFromTeam(t.id, adminUser.id);
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Join Gate ${open ? 'Public' : 'Private'} ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [t.id]);
return t;
};
const publicGoverned = await newGovernedTeam(true);
const privateGoverned = await newGovernedTeam(false);
// The private team's enforcement reaches the join gate through a materialized
// view and a read replica. Poll the admin's for_directory listing until the
// team drops out: the admin always has it in-candidate (list_private_teams),
// so it dropping out positively confirms the policy is live AND the PDP
// denies — exactly the state the join gate evaluates. Polling the
// non-qualifying user's listing would be ambiguous (absent both before the
// policy propagates and after it is hidden).
const base = adminClient.getBaseRoute();
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
await expect
.poll(
async () => {
try {
const resp = await fetch(`${base}/teams?page=0&per_page=200&for_directory=true`, {headers});
return ((await resp.json()) as Team[]).map((t) => t.id).includes(privateGoverned.id);
} catch {
return true;
}
},
{timeout: 60_000, intervals: [1000, 2000, 5000, 5000, 5000]},
)
.toBe(false);
// Confirm the public team is genuinely governed before asserting advisory
// admission — otherwise a "successful join" would just be a plain public team
// and a regression to strict-on-public would slip through.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// Advisory: the gate is skipped on a governed public team, so the
// non-qualifying user is admitted.
const member = await adminClient.addToTeam(publicGoverned.id, nonQualUser.id);
expect(member.user_id).toBe(nonQualUser.id);
// Strict: the same user is denied on the private team — directory visibility
// never translates into join access, and no role bypasses the gate.
let denied = false;
try {
await adminClient.addToTeam(privateGoverned.id, nonQualUser.id);
} catch {
denied = true;
}
expect(denied).toBe(true);
});
});

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import {expect, test} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {createUserForABAC, ensureUserAttributes} from '../support';
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
/**
* ABAC — Team directory "Recommended" tag
*
* A public (open-invite) governed team is advisory: it stays visible to everyone,
* and a non-member who satisfies the policy is surfaced a transient, per-viewer
* "recommended" hint. The flag is fail-secure (a non-qualifying viewer is never
* tagged) and is never set on private governed teams (those are hidden, not
* recommended). Asserted at the API layer so it proves the server-side annotation
* across the full api4 → app filter/annotate → store-listing chain, plus a UI check
* that the chip renders in the team-selection directory.
*
* @reference MM-69100
*/
test.describe('ABAC - Team directory recommended tag', {tag: ['@abac', '@team_membership']}, () => {
let cleanupClient: Client4 | undefined;
const createdTeamIds: string[] = [];
const createdUserIds: string[] = [];
const createdPolicyIds: string[] = [];
test.afterEach(async () => {
const client = cleanupClient;
cleanupClient = undefined;
const policyIds = createdPolicyIds.splice(0);
const teamIds = createdTeamIds.splice(0);
const userIds = createdUserIds.splice(0);
if (!client) {
return;
}
const base = client.getBaseRoute();
const headers = {Authorization: `Bearer ${client.getToken()}`};
for (const id of policyIds) {
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
}
for (const id of teamIds) {
await client.deleteTeam(id).catch(() => {});
}
for (const id of userIds) {
await client.updateUserActive(id, false).catch(() => {});
}
});
test('MM-69100-T1 - recommends a public governed team to a qualifying non-member only', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(qualUser.id, nonQualUser.id);
// A public (open-invite) governed team: advisory, so it stays visible to all;
// qualifying non-members get the recommended hint. open-invite is set via patch
// (the create payload does not persist it).
const publicGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Public Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Recommended Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// API layer: the qualifying non-member's listing tags the team recommended;
// the non-qualifying user's listing does not (fail-secure).
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
await expect
.poll(
async () => {
const teams = (await qualClient.getTeams(0, 200)) as Team[];
return teams.find((t) => t.id === publicGoverned.id)?.recommended === true;
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'public governed team should be recommended to the qualifying user',
},
)
.toBe(true);
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
const nonQualRow = nonQualTeams.find((t) => t.id === publicGoverned.id);
expect(nonQualRow).toBeDefined(); // advisory: still visible
expect(Boolean(nonQualRow?.recommended)).toBe(false);
});
test('MM-69100-T2 - never recommends a private governed team, even to a qualifying user', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
createdUserIds.push(qualUser.id);
// A private (non-open-invite) governed team: strict. It is surfaced to the
// qualifying user (directory widening) but must never carry the recommended tag.
const privateGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Private Governed ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(privateGoverned.id);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Private Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [privateGoverned.id]);
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
await expect
.poll(async () => (await adminClient.getTeam(privateGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
// The qualifying user can see the private governed team (strict widening), but it
// is never recommended — recommendation is a public-team-only, advisory concept.
await expect
.poll(
async () => {
const teams = (await qualClient.getTeams(0, 200)) as Team[];
return teams.some((t) => t.id === privateGoverned.id);
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'qualifying user should see the private governed team before asserting recommendation',
},
)
.toBe(true);
const teams = (await qualClient.getTeams(0, 200)) as Team[];
const row = teams.find((t) => t.id === privateGoverned.id);
expect(Boolean(row?.recommended)).toBe(false);
});
test('MM-69100-T3 - renders the Recommended chip in the team-selection directory for a qualifying user', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
createdUserIds.push(qualUser.id);
// Prefix with "AA" so the team sorts near the top of most environments.
// The UI check in Part 2 below walks InfiniteScroll regardless, so an
// exact page-0 guarantee is not required.
const teamId = pw.random.id();
const publicGoverned = await adminClient.createTeam({
name: `team-${teamId}`,
display_name: `AA Recommended${teamId}`,
type: 'O',
email: `${teamId}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Recommended UI Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
// Wait for the materialized attribute view to include the qualifying user
// before asserting policy propagation — the PDP reads from this view.
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
// The child policy row is written to the master DB; the directory query
// reads from a read replica. Poll policy_enforced until the replica has
// caught up so the EXISTS subquery in GetAllPage widens correctly.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'team should show policy_enforced=true before asserting recommended',
})
.toBe(true);
const {page} = await pw.testBrowser.login(qualUser);
await page.goto('/select_team');
await page.waitForLoadState('networkidle');
const chip = page
.locator('.signup-team-dir')
.filter({hasText: publicGoverned.display_name})
.getByLabel('Recommended based on your attributes');
await expect(chip).toBeVisible({timeout: 15000});
});
});

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
/**
* Enable team-membership ABAC end to end: the umbrella attribute-based access
* control setting plus the `TeamMembershipAccessControl` feature flag that gates
* the per-team System Console section. Both must be on for the team page to
* render the membership-policy toggle — mirroring the server enforcement gate.
*/
export async function enableTeamMembershipPolicies(client: Client4): Promise<void> {
await client.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
FeatureFlags: {
TeamMembershipAccessControl: true,
},
} as any);
}
/**
* Create a parent membership policy that can be assigned to a TEAM.
*
* Team assignment runs `child.Inherit(parent)` with the child at v0.3, which
* requires the parent to also be v0.3 — so (unlike the channel-side
* createParentPolicy, which is v0.2) this helper stamps v0.3 and a membership
* action. Returns the created policy (carries `id` and `name`).
*/
export async function createTeamMembershipParentPolicy(
client: Client4,
name: string,
expression: string,
): Promise<{id: string; name: string}> {
return (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies`, {
method: 'put',
body: JSON.stringify({
id: '',
name,
type: 'parent',
version: 'v0.3',
revision: 0,
rules: [{expression, actions: ['membership']}],
}),
});
}
/**
* Trigger an access_control_team_sync job and poll that specific job until it
* finishes. Polling by ID (not list position) avoids a race where older jobs
* occupy jobs[0] and the newly created one is never checked.
*
* Both `success` and `warning` are terminal completions: the worker reports
* `warning` (not `success`) whenever the mass-removal guardrail trips — i.e. a
* sync that drops >50% of a team. The sync still ran to completion, so callers
* that exercise removals must accept it. The final status is returned so a
* caller can assert on it (e.g. expecting the warning state).
*/
export async function triggerSyncJobAndPoll(
client: Client4,
policyId = '',
timeoutMs = 90_000,
pollIntervalMs = 3_000,
): Promise<string> {
// Scope the sync to the team's policy (team policies are keyed by team id),
// mirroring the product trigger createAccessControlTeamSyncJob({policy_id}).
// A scoped sync also chains a scoped channel sync, so the chained job is
// created deterministically rather than skipped by the unscoped dedupe.
const body: {type: string; data?: {policy_id: string}} = {type: 'access_control_team_sync'};
if (policyId) {
body.data = {policy_id: policyId};
}
const job: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs`, {
method: 'POST',
body: JSON.stringify(body),
});
const jobId: string = job.id;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const current: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs/${jobId}`, {method: 'GET'});
if (current.status === 'success' || current.status === 'warning') {
return current.status;
}
if (current.status === 'error') {
throw new Error(`access_control_team_sync job failed: ${JSON.stringify(current)}`);
}
}
throw new Error('Timed out waiting for access_control_team_sync job to finish');
}
/**
* Assign a parent policy to teams via the REST API (the `team_ids` field).
* Mirrors `assignChannelsToPolicy` from team_settings/helpers — same endpoint,
* different resource list.
*/
export async function assignTeamsToPolicy(client: Client4, policyId: string, teamIds: string[]): Promise<void> {
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({team_ids: teamIds}),
});
if (!response.ok) {
throw new Error(`assignTeamsToPolicy failed: ${response.status}`);
}
}

View File

@@ -0,0 +1,251 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective API-level enforcement gate: non-qualifying users are rejected when joining a private
* ABAC-governed team; the same call is allowed on a public (advisory) team.
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
ensureDepartmentAttribute,
enableTeamMembershipABACConfig,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
// Raw fetch wrapper — returns status + body so tests can assert on both success and rejection
// without doFetch swallowing the error.
async function addTeamMemberRaw(
token: string | null,
baseRoute: string,
teamId: string,
userId: string,
): Promise<{status: number; body: any}> {
const res = await fetch(`${baseRoute}/teams/${teamId}/members`, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
body: JSON.stringify({team_id: teamId, user_id: userId}),
});
let body: any = {};
try {
body = await res.json();
} catch {
// empty body is fine
}
return {status: res.status, body};
}
// Log in via raw REST to obtain the user's OWN session token, so a self-join can be
// attempted with the requesting user's session rather than the admin's (which would
// bypass the attribute gate). The token is returned in the response 'Token' header.
async function loginRaw(baseRoute: string, loginId: string, password: string): Promise<string> {
const res = await fetch(`${baseRoute}/users/login`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({login_id: loginId, password}),
});
return res.headers.get('Token') ?? '';
}
test.describe('ABAC - Join enforcement gate (API level)', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T8 non-qualifying user is rejected at the API level on a private ABAC team', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create private team governed by the Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 (qualifies) and mkt1 (does not qualify)
const makeUser = async (dept: string, label: string) => {
const uid = `${label}${suffix}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await makeUser('Engineering', 'eng1gate');
const mkt1 = await makeUser('Marketing', 'mkt1gate');
createdUserIds.push(eng1.id, mkt1.id);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// Wait for the materialized view to reflect both users before hitting the gate
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
const token = adminClient.getToken();
const baseRoute = adminClient.getBaseRoute();
// * Non-qualifying user (Marketing) is rejected with 403
const mktResult = await addTeamMemberRaw(token, baseRoute, team.id, mkt1.id);
expect(mktResult.status).toBe(403);
// * Error message is generic — must NOT leak policy name, attribute name or CEL expression
const errMsg: string = mktResult.body?.message ?? '';
expect(errMsg).not.toMatch(/Engineering/i);
expect(errMsg).not.toMatch(/Department/i);
expect(errMsg).not.toMatch(/policy/i);
expect(errMsg.length).toBeGreaterThan(0);
// * mkt1 is NOT a member of the team
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds).not.toContain(mkt1.id);
// * Qualifying user (Engineering) is accepted with 201 (Created)
const engResult = await addTeamMemberRaw(token, baseRoute, team.id, eng1.id);
expect(engResult.status).toBe(201);
// * eng1 is now a member
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIdsAfter = membersAfter.map((m: any) => m.user_id);
expect(memberIdsAfter).toContain(eng1.id);
});
test('MM-69100-T9 non-qualifying user is allowed at the API level on a public ABAC team (advisory)', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create public team — advisory mode, same Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
const uid = `mkt2gate${suffix}`;
const mkt2 = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, mkt2.id, 'Department', 'Marketing');
createdUserIds.push(mkt2.id);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
const token = adminClient.getToken();
const baseRoute = adminClient.getBaseRoute();
// * Non-qualifying user is allowed on a public ABAC team — advisory, no gate
const result = await addTeamMemberRaw(token, baseRoute, team.id, mkt2.id);
expect(result.status).toBe(201);
// * mkt2 is a member despite not matching the policy
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds).toContain(mkt2.id);
});
test('MM-69100-T10 qualifying user self-joins a private ABAC team by attribute match; non-qualifying is rejected', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team governed by the Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Two regular users (no join_private_teams role): eng qualifies, mkt does not.
// Capture their passwords so we can log in as them and self-join.
const makeUser = async (dept: string, label: string) => {
const uid = `${label}${suffix}`;
const password = newTestPassword();
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
createdUserIds.push(user.id);
return {user, password};
};
const eng = await makeUser('Engineering', 'engself');
const mkt = await makeUser('Marketing', 'mktself');
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng.user.id]);
const baseRoute = adminClient.getBaseRoute();
// # Qualifying user logs in and joins themselves with their OWN session
const engToken = await loginRaw(baseRoute, eng.user.username, eng.password);
expect(engToken.length).toBeGreaterThan(0);
// * Attribute match authorizes the self-join even without the join_private_teams role
const engResult = await addTeamMemberRaw(engToken, baseRoute, team.id, eng.user.id);
expect(engResult.status).toBe(201);
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
expect(members.map((m: any) => m.user_id)).toContain(eng.user.id);
// # Non-qualifying user attempts the same self-join with their own session
const mktToken = await loginRaw(baseRoute, mkt.user.username, mkt.password);
expect(mktToken.length).toBeGreaterThan(0);
// * Rejected — the attribute gate applies to the requesting user's session too
const mktResult = await addTeamMemberRaw(mktToken, baseRoute, team.id, mkt.user.id);
expect(mktResult.status).toBe(403);
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
expect(membersAfter.map((m: any) => m.user_id)).not.toContain(mkt.user.id);
});
});

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective A team sync removal cascades to private channel membership.
*
* When a user is removed from a private ABAC-governed team, the server calls
* LeaveTeam which synchronously removes that user from every private channel
* in the team. This spec exercises the full path end-to-end:
*
* policy update → team sync → team removal → channel cascade (LeaveTeam)
*
* Two users start in both the team and a private channel. Updating the policy
* to exclude one of them removes that user from the team AND the channel, while
* the qualifying user is unaffected.
*
* Assertions run at two layers:
* 1. API — direct membership queries confirm the server state.
* 2. UI — browser logins confirm what each user actually sees rendered.
*
* @reference MM-69100
*/
import {ChannelsPage, expect, newTestPassword, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import {
createPrivateChannel,
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
test.describe('ABAC - Team Sync Channel Cascade', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(240000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
await adminClient.deleteTeam(id).catch(() => {});
}
createdTeamIds = [];
for (const id of createdUserIds) {
await adminClient.updateUserActive(id, false).catch(() => {});
}
createdUserIds = [];
});
/**
* When the sync job removes a non-qualifying user from a private team, the
* underlying LeaveTeam call also removes them from every private channel in
* that team. Validated at both the API layer (membership queries) and the UI
* layer (browser logins for each affected user).
*/
test('MM-69100-T_cascade - team sync removal cascades to private channel membership', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
if (!adminClient) {
throw new Error('Admin client not available');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team (allow_open_invite=false → strict ABAC enforcement)
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
const {adminUser} = await pw.getAdminClient();
if (adminUser) {
await adminClient.removeFromTeam(team.id, adminUser.id).catch(() => {});
}
// # Private channel inside the governed team — the cascade target
const privateChannel = await createPrivateChannel(adminClient, team.id);
// # Public home team for mkt1. A user removed from their only team lands
// # on a "Team not found" error and cannot navigate anywhere meaningful.
// # homeTeam gives mkt1 a valid context for the UI assertions below.
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
createdTeamIds.push(homeTeam.id);
// # Create eng1 (Engineering) and mkt1 (Marketing) — track passwords for browser login
const eng1Password = newTestPassword();
const eng1 = await adminClient.createUser(
{
email: `eng1${suffix}@sample.mattermost.com`,
username: `eng1${suffix}`,
password: eng1Password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, eng1.id, 'Department', 'Engineering');
const mkt1Password = newTestPassword();
const mkt1 = await adminClient.createUser(
{
email: `mkt1${suffix}@sample.mattermost.com`,
username: `mkt1${suffix}`,
password: mkt1Password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
await adminClient.addToTeam(homeTeam.id, mkt1.id);
createdUserIds.push(eng1.id, mkt1.id);
// Gate on the materialized attribute view — ABAC sync reads from a Postgres
// view that refreshes at most once every 30 s. Freshly-written attributes
// are not visible until the next refresh tick.
await waitForAttributeViewToInclude(
adminClient,
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
[eng1.id, mkt1.id],
);
// =========================================================
// Phase 1 — policy admits both; sync auto-adds both to team
// =========================================================
await createTeamMembershipPolicy(
adminClient,
team.id,
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
true, // active = auto-add
);
await triggerSyncJobAndPoll(adminClient, team.id);
// * [API] Both are team members after first sync
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 15000, intervals: [500, 1000, 2000], message: 'both users should be auto-added to team'},
)
.toEqual(expect.arrayContaining([eng1.id, mkt1.id]));
// Team sync adds users to the team (and town-square) only — not to
// arbitrary private channels. Add both manually so the cascade has something to remove.
await adminClient.addToChannel(eng1.id, privateChannel.id);
await adminClient.addToChannel(mkt1.id, privateChannel.id);
// * [API] Both in private channel — baseline confirmed
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, mkt1.id, privateChannel.id)).toBe(true);
// =========================================================
// Phase 2 — policy narrows to Engineering; mkt1 loses access
// =========================================================
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// Strict mode: sync removes mkt1 from team; LeaveTeam cascades to channels
await triggerSyncJobAndPoll(adminClient, team.id);
// * [API] mkt1 removed from team
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 15000, intervals: [500, 1000, 2000], message: 'mkt1 should be removed from team'},
)
.not.toContain(mkt1.id);
// * [API] mkt1 cascade-removed from private channel
await expect
.poll(() => verifyUserInChannel(adminClient, mkt1.id, privateChannel.id), {
timeout: 15000,
intervals: [500, 1000, 2000],
message: 'mkt1 should be cascade-removed from private channel',
})
.toBe(false);
// * [API] eng1 still in team and channel
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 10000, intervals: [500, 1000], message: 'eng1 should remain in team'},
)
.toContain(eng1.id);
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
// =========================================================
// UI — mkt1: governed team gone from sidebar; channel inaccessible
// =========================================================
const mkt1WithPassword = {...mkt1, password: mkt1Password};
const {page: mkt1Page} = await pw.testBrowser.login(mkt1WithPassword);
const mkt1ChannelsPage = new ChannelsPage(mkt1Page);
// Land on homeTeam so we have a valid context to inspect the sidebar from
await mkt1ChannelsPage.goto(homeTeam.name, 'town-square');
await mkt1ChannelsPage.toBeVisible();
// * Navigating to the governed team URL redirects mkt1 away (no membership)
await mkt1Page.goto(`/${team.name}/channels/town-square`);
await mkt1Page.waitForLoadState('networkidle');
await expect(mkt1Page).not.toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
// =========================================================
// UI — eng1: governed team and private channel still accessible
// =========================================================
const eng1WithPassword = {...eng1, password: eng1Password};
const {page: eng1Page} = await pw.testBrowser.login(eng1WithPassword);
const eng1ChannelsPage = new ChannelsPage(eng1Page);
// * eng1 can access the governed team — URL confirms they are on it
// (team sidebar icon only renders when the user belongs to multiple teams,
// so we assert the route instead of the sidebar element)
await eng1ChannelsPage.goto(team.name, 'town-square');
await eng1ChannelsPage.toBeVisible();
await expect(eng1Page).toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
// * Private channel is accessible to eng1
await eng1ChannelsPage.goto(team.name, privateChannel.name);
await eng1ChannelsPage.toBeVisible();
await expect(eng1Page.locator('#channelHeaderTitle', {hasText: privateChannel.display_name})).toBeVisible({
timeout: 10000,
});
});
});

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Sync Job Details modal shows per-team added/removed counts and the mass-removal warning
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPrivateTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
test.describe('ABAC - Sync Job Details Modal', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T4 sync job details shows per-team member counts and section title', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # The creator is auto-added; drop them so the member set is exactly the
// # two users below — keeps the removal count deterministic and below the
// # >50% mass-removal guardrail.
await adminClient.removeFromTeam(team.id, adminUser.id);
// # eng1 is a qualifying member; mkt1 is non-qualifying (will be removed by sync)
const createUser = async (dept: string, label: string, addToTeam: boolean) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
if (addToTeam) {
await adminClient.addToTeam(team.id, user.id);
}
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`, true);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`, true);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Active policy (strict mode removes non-qualifiers)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for completion
await triggerSyncJobAndPoll(adminClient, team.id);
// # Log in and navigate to sync jobs page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// * Section title is "Membership sync jobs" (capital S verified in en.json)
await expect(page.getByText(/Membership sync jobs/i)).toBeVisible({timeout: 10000});
// # Open the most recent job's details (the channel sync chained from the team sync)
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
await expect(jobRow).toBeVisible({timeout: 10000});
// * Row exposes a visible "View details" affordance (MM-69827 finding 12)
await expect(jobRow.getByText('View details')).toBeVisible();
await jobRow.click();
const detailsModal = page.locator('#job-details-modal');
await expect(detailsModal).toBeVisible({timeout: 15000});
// # Switch to the Teams tab where the team membership changes are surfaced
await detailsModal.getByRole('button', {name: 'Teams'}).click();
// * Team row present for our team
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
await expect(teamRow).toBeVisible({timeout: 10000});
// * Removed count shows -1 (mkt1 removed)
await expect(teamRow.locator('.changes-cell .removed')).toHaveText(/-1/);
// # Click team row to open the user drill-down modal
await teamRow.click();
await expect(page.locator('#user-list-modal-dialog')).toBeVisible({timeout: 10000});
});
test('MM-69827-T1 Membership Sync Jobs names the channel scope and points to team sync when team ABAC is on', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// * Button names its scope so a green success isn't mistaken for a team re-sync
await expect(page.getByRole('button', {name: /Run Channel Sync/i})).toBeVisible({timeout: 10000});
// * A note points admins to where team-membership sync actually lives
await expect(page.getByText(/This syncs channel membership only/i)).toBeVisible({timeout: 10000});
await expect(page.getByText(/Team Settings/i)).toBeVisible();
});
test('MM-69100-T5 sync job details shows mass-removal warning when >50% of members are removed', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # The creator is auto-added; drop them so the member set is exactly the
// # four users below (1 qualifying + 3 not), giving a clean 75% removal.
await adminClient.removeFromTeam(team.id, adminUser.id);
// # Add 1 qualifying member and 3 non-qualifying members so >50% are removed
const createAndAdd = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await adminClient.addToTeam(team.id, user.id);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createAndAdd('Engineering', `eng${suffix}`);
const mkt1 = await createAndAdd('Marketing', `mkt1${suffix}`);
const mkt2 = await createAndAdd('Marketing', `mkt2${suffix}`);
const mkt3 = await createAndAdd('Marketing', `mkt3${suffix}`);
createdUserIds.push(eng1.id, mkt1.id, mkt2.id, mkt3.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Active policy — 3 of 4 members removed (75%)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for completion
await triggerSyncJobAndPoll(adminClient, team.id);
// # Navigate to sync jobs page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Open the most recent job's details (the channel sync chained from the team sync)
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
await expect(jobRow).toBeVisible({timeout: 10000});
await jobRow.click();
const detailsModal = page.locator('#job-details-modal');
await expect(detailsModal).toBeVisible({timeout: 15000});
// # Switch to the Teams tab where the team membership changes are surfaced
await detailsModal.getByRole('button', {name: 'Teams'}).click();
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
await expect(teamRow).toBeVisible({timeout: 10000});
// * Mass-removal warning indicator present (>50% removed)
await expect(teamRow.locator('.mass-removal-warning')).toBeVisible({timeout: 10000});
});
});

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective A real team sync job creates the addition/removal system posts, which are
* rendered correctly in the affected user's DM from the system bot.
* @reference MM-69100
*/
import {ChannelsPage, expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPublicTeam,
createPrivateTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
const SYSTEM_BOT_USERNAME = 'system-bot';
test.describe('ABAC - Sync System Messages', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T6 qualifying non-member receives an addition DM after a sync job', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Public team + active Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create joiner with Engineering attribute — NOT yet a team member
const joinerUid = `joiner${suffix}`;
const joinerPassword = newTestPassword();
const joiner = await adminClient.createUser(
{
email: `${joinerUid}@sample.mattermost.com`,
username: joinerUid,
password: joinerPassword,
} as any,
'',
'',
);
createdUserIds.push(joiner.id);
await setUserAttribute(adminClient, joiner.id, 'Department', 'Engineering');
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [joiner.id]);
// # Active policy — auto-add will add joiner on next sync
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for it to complete
await triggerSyncJobAndPoll(adminClient);
// * joiner is now a team member
const members: any[] = await adminClient.getTeamMembers(team.id);
const joinerMember = members.find((m: any) => m.user_id === joiner.id);
expect(joinerMember).toBeDefined();
// # Locate the system-bot user
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
expect(botUsers.length).toBeGreaterThan(0);
const systemBot = botUsers[0];
// # Get or create the DM channel between joiner and system-bot
const dmChannel = await adminClient.createDirectChannel([joiner.id, systemBot.id]);
expect(dmChannel).toBeDefined();
// # Log in as joiner and navigate to the DM
const joinerWithPassword = {...joiner, password: joinerPassword};
const {page} = await pw.testBrowser.login(joinerWithPassword);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, dmChannel.name);
await channelsPage.toBeVisible();
// * Addition system message rendered in the DM (scoped to this run's team name
// to avoid matching accumulated messages from prior test runs in the same DM).
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const expectedText = new RegExp(
`You have been added to ${escapedName} because you now meet the membership requirements`,
'i',
);
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
timeout: 15000,
});
});
test('MM-69100-T7 removed member receives a removal DM after a sync job', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # A second, non-governed team that mkt1 keeps after being removed from the
// governed one. A user with zero teams lands on a "Team Not Found" page and
// can't reach the system-bot DM, so they need a team context to view it.
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
createdTeamIds.push(homeTeam.id);
// # mkt1 has Marketing — does not match Engineering rule; IS a current member
const mkt1Uid = `mkt1${suffix}`;
const mkt1Password = newTestPassword();
const mkt1 = await adminClient.createUser(
{
email: `${mkt1Uid}@sample.mattermost.com`,
username: mkt1Uid,
password: mkt1Password,
} as any,
'',
'',
);
createdUserIds.push(mkt1.id);
await adminClient.addToTeam(team.id, mkt1.id);
await adminClient.addToTeam(homeTeam.id, mkt1.id);
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Marketing"', [mkt1.id]);
// # Active policy (strict) — mkt1 will be removed
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
await triggerSyncJobAndPoll(adminClient, team.id);
// * mkt1 is no longer a team member
const members: any[] = await adminClient.getTeamMembers(team.id);
const mkt1Member = members.find((m: any) => m.user_id === mkt1.id);
expect(mkt1Member).toBeUndefined();
// # Ensure the DM channel between mkt1 and system-bot exists before logging in
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
const systemBot = botUsers[0];
const dmChannel = await adminClient.createDirectChannel([mkt1.id, systemBot.id]);
// # Log in as mkt1 and open the system-bot DM from the retained team's context
const mkt1WithPassword = {...mkt1, password: mkt1Password};
const {page} = await pw.testBrowser.login(mkt1WithPassword);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(homeTeam.name, dmChannel.name);
await channelsPage.toBeVisible();
// * Removal system message rendered (scoped to this run's team name).
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const expectedText = new RegExp(
`You have been removed from ${escapedName} because you no longer meet the membership requirements`,
'i',
);
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
timeout: 15000,
});
});
});