[MM-68497] Enables membership policies on public channels with advisory semantics (#36275)

This commit is contained in:
Ibrahim Serdar Acikgoz 2026-04-30 00:56:32 +02:00 committed by GitHub
parent 6c0e0fee4a
commit 4da11e81af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3500 additions and 465 deletions

View file

@ -995,6 +995,53 @@
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/teams/{team_id}/channels/recommended":
get:
tags:
- channels
summary: Get recommended public channels for the current user
description: |
Return the public channels on a team that have a membership policy
assigned, where the requesting user's attributes match to the policy.
Membership policies on public channels are advisory: anyone can still join
these channels. This endpoint surfaces them as "Recommended channels"
for the requester.
Returns an empty list if the Enterprise Advanced license is not
active, if `AccessControlSettings.EnableAttributeBasedAccessControl`
is `false`, or if the user's attributes do not match any active
public-channel policy in the team.
__Minimum server version__: 11.8
##### Permissions
Must be authenticated and have `list_team_channels` on the team.
operationId: GetRecommendedChannelsForTeam
parameters:
- name: team_id
in: path
description: Team GUID
required: true
schema:
type: string
responses:
"200":
description: Recommended channels retrieval successful
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Channel"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/teams/{team_id}/channels/deleted":
get:
tags:

View file

@ -437,6 +437,25 @@
group constrains.
schema:
type: boolean
- name: abac_match_only
in: query
description: >
When used with `not_in_channel`, restricts the result to users whose
attributes satisfy the channel's Attribute-Based Access Control
(ABAC) membership policy.
On private channels with an ABAC policy this filter is always
applied regardless of this parameter (hard gate). On public channels
with an advisory ABAC policy the full not_in_channel candidate list
is returned by default; set this to `true` to fetch only the
matching subset of candidates (for example to annotate recommended
members in the invite UI).
__Minimum server version__: 11.8
schema:
type: boolean
- name: without_team
in: query
description: Whether or not to list users that are not on any team. This option

View file

@ -0,0 +1,241 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective E2E coverage for public-channel ABAC behavior:
* - Default channels are not eligible for ABAC policies (channel-settings + team-settings paths)
* - A policy on a public channel surfaces matching users a "Recommended" channel entry
*
* @reference Public-channel ABAC feature
*/
import {ChannelsPage, expect, getAdminClient, test} from '@mattermost/playwright-lib';
import {enableABACConfig, enableAPIUserDeletion} from '../team_settings/helpers';
import {
CleanupLedger,
createPolicyAssignedToChannels,
createTrackedAttribute,
createTrackedPublicChannel,
createTrackedTeamMember,
runAccessControlSyncJob,
setChannelPolicyActive,
waitForJobCompletion,
} from './helpers';
test.describe('ABAC - Public channels', () => {
let ledger: CleanupLedger;
// Enable the permanent-delete API once for the whole describe so the
// CleanupLedger's permanentDeleteUser cleanup tasks succeed instead of
// spamming "Permanent user deletion feature is not enabled" on teardown.
// `pw` is test-scoped, so we use the worker-scoped getAdminClient here.
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
if (!adminClient) {
return;
}
await enableAPIUserDeletion(adminClient);
});
test.beforeEach(() => {
ledger = new CleanupLedger();
});
test.afterEach(async () => {
await ledger.drain();
});
test('Default channel hides Membership Policy tab in Channel Settings', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
// # Open Channel Settings on the default (town-square) channel
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
// * Membership Policy tab is NOT rendered for the default channel —
// ValidateChannelEligibilityForAccessControl rejects default channels
// server-side, so the tab would only let a user assemble unsaveable rules.
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
await channelSettings.close();
});
test('Default channel is excluded from Team Settings policy editor channel selector', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
// # Navigate to Team Settings → Membership Policies → New policy editor
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();
await teamSettings.container.getByRole('button', {name: 'Add policy'}).click();
// # Open the channel selector modal
await teamSettings.container.getByRole('button', {name: /Add channels/i}).click();
const channelSelector = page.locator('.channel-selector-modal');
await channelSelector.waitFor();
// * Both default channels (town-square, off-topic) are absent from the list.
// The component contract is `excludeDefaultChannels={true}` — assert the
// canonical channel rows by their stable test ids rather than display text.
await expect(channelSelector.locator('[data-testid="ChannelRow-town-square"]')).toHaveCount(0);
await expect(channelSelector.locator('[data-testid="ChannelRow-off-topic"]')).toHaveCount(0);
await teamSettings.close();
});
test('Public channel with matching policy surfaces as Recommended for matching user (auto-add off)', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
// # Per-test custom profile attribute. We don't reuse a shared "Department"
// field — the server caps CPA fields at 20, and accumulating shared
// state across tests has historically saturated that limit and broken
// subsequent runs. This field gets cleaned up via the ledger.
const attribute = await createTrackedAttribute(adminClient, 'PubAbacDept', ledger);
// # Public channel + parent policy with rule "<attr> == 'engineering'",
// policy assigned to that channel. Auto-add stays OFF (server default
// on a freshly-created child policy).
const publicChannel = await createTrackedPublicChannel(adminClient, team.id, ledger);
// CEL references the attribute by its field name (lowercased to keep
// the identifier characters CEL-safe — uppercase chars in identifiers
// are technically valid but the table-editor parser used in the webapp
// round-trips through lowercase, so this matches reality).
const expression = `user.attributes.${attribute.name} == 'engineering'`;
await createPolicyAssignedToChannels(
adminClient,
`Public Recommend ${Date.now()}`,
expression,
[publicChannel.id],
ledger,
);
// # Matching user — value matches the rule, member of the team but NOT
// of the channel. Auto-add is off, so they must NOT be auto-joined;
// the channel should instead appear in their Recommended list.
const matchingUser = await createTrackedTeamMember(
adminClient,
team.id,
{fieldId: attribute.id, value: 'engineering'},
ledger,
);
// * Auto-add OFF contract: the matching user must NOT have been silently
// joined to the channel. Verified server-side before any UI flow so a
// regression that auto-adds them would fail here, regardless of UI
// behavior. The Browse-Channels assertion below proves the channel
// *appears as a recommendation* — but only this membership check
// distinguishes "recommended (advisory)" from "auto-added".
const preMembers = await adminClient.getChannelMembers(publicChannel.id);
expect(
preMembers.map((m: any) => m.user_id),
'matching user must not be auto-joined when auto-add is OFF (advisory recommendation only)',
).not.toContain(matchingUser.id);
const {page} = await pw.testBrowser.login(matchingUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
// # Open Browse Channels and switch the type filter to "Recommended channels"
const browse = await channelsPage.openBrowseChannelsModal();
await browse.toBeDoneLoading();
// The recommended menu item is keyed by `id="channelsMoreDropdownRecommended"`,
// gated by the `showRecommendedFilter` prop which is set whenever ABAC is
// enabled in client config. Using the stable id avoids brittleness from
// the dropdown's localized label.
await browse.container.locator('#menuWrapper').click();
await page.locator('#channelsMoreDropdownRecommended').click();
// * The public channel appears in the Recommended results. The row id
// is generated by the SearchableChannelList component as
// `ChannelRow-${channel.name}` — assert against that, not the display
// text, which can be locale-dependent.
const channelRow = browse.container.locator(`[data-testid="ChannelRow-${publicChannel.name}"]`);
await expect(channelRow).toBeVisible({timeout: 15000});
});
test('Public channel with auto-add ON adds matching users; non-matching members are NOT removed', async ({pw}) => {
// The sync job is queued and processed asynchronously — give the test
// enough headroom for the queue + evaluation + member writes. Empirically
// it finishes in <10s on a quiet dev server, but CI workers are slower.
test.setTimeout(120_000);
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
const attribute = await createTrackedAttribute(adminClient, 'PubAbacAuto', ledger);
const publicChannel = await createTrackedPublicChannel(adminClient, team.id, ledger);
const expression = `user.attributes.${attribute.name} == 'engineering'`;
await createPolicyAssignedToChannels(
adminClient,
`Public Auto-Add ${Date.now()}`,
expression,
[publicChannel.id],
ledger,
);
// # Auto-add ON: flip the channel-scope (child) policy's Active flag.
// The parent default is inactive, and children inherit Active=false at
// assign time, so we activate the child directly here. The sync job
// only auto-adds members for Active policies.
await setChannelPolicyActive(adminClient, publicChannel.id, true);
// # Two users:
// - matching: Department=engineering, in team but NOT in channel.
// Sync should auto-add them.
// - nonMatching: Department=sales, already a member of the channel.
// For PUBLIC channels, ABAC is advisory: sync must NOT
// remove them. (For private channels, sync would.)
const matching = await createTrackedTeamMember(
adminClient,
team.id,
{fieldId: attribute.id, value: 'engineering'},
ledger,
);
const nonMatching = await createTrackedTeamMember(
adminClient,
team.id,
{fieldId: attribute.id, value: 'sales'},
ledger,
);
await adminClient.addToChannel(nonMatching.id, publicChannel.id);
// # Trigger sync against this channel-scope policy and wait for it to
// reach a terminal state. We pass `publicChannel.id` (the child
// policy ID) rather than the parent ID so the sync stays scoped to
// this test and ignores any other policies on the dev server.
const job = await runAccessControlSyncJob(adminClient, publicChannel.id);
const finished = await waitForJobCompletion(adminClient, job.id);
expect(finished.status, `sync job did not succeed: ${JSON.stringify(finished)}`).toBe('success');
// * Membership state via API — no UI flake.
const members = await adminClient.getChannelMembers(publicChannel.id);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds, 'matching user must be auto-added by sync').toContain(matching.id);
expect(
memberIds,
'non-matching member must NOT be removed (public channels are advisory under ABAC)',
).toContain(nonMatching.id);
});
});

View file

@ -132,7 +132,7 @@ async function createChannelAccessRule(
async function openAccessControlSettings(channelsPage: ChannelsPage) {
const channelSettings = await channelsPage.openChannelSettings();
const accessControlTab = channelSettings.container.getByRole('tab', {name: /Access Control/i});
const accessControlTab = channelSettings.container.getByRole('tab', {name: /Membership Policy/i});
await expect(accessControlTab).toBeVisible({timeout: 10000});
await accessControlTab.click();
@ -155,7 +155,7 @@ async function testAccessRuleAndVerifyUser(page: Page, username: string) {
await expect(modal.getByText(`@${username}`)).toBeVisible({timeout: 10000});
}
test.describe('Channel Settings → Access Control', () => {
test.describe('Channel Settings → Membership Policy', () => {
test('MM-68538 channel admin can test access rule for multiselect "has any of" with multiple values', async ({
pw,
}) => {
@ -163,7 +163,7 @@ test.describe('Channel Settings → Access Control', () => {
const {adminClient, team} = await pw.initSetup();
// Enable ABAC + user-managed attributes so the Access Control tab and
// Enable ABAC + user-managed attributes so the Membership Policy tab and
// the test access rule flow are available.
await adminClient.patchConfig({
AccessControlSettings: {
@ -201,7 +201,7 @@ test.describe('Channel Settings → Access Control', () => {
await createChannelAccessRule(adminClient, channel, aliceExcludingExpression);
// Alice can open Channel Settings → Access Control as channel admin
// Alice can open Channel Settings → Membership Policy as channel admin
// and test the existing rule through the same UI users exercise.
const {page} = await pw.testBrowser.login(alice);
const channelsPage = new ChannelsPage(page);

View file

@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
/**
* @objective E2E tests for the Access Control tab in Channel Settings Modal
* @reference MM-67326
* @objective E2E tests for the Membership Policy tab (access_rules) in Channel Settings Modal
* @reference MM-67326 public and private channels can carry ABAC membership policies
*/
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
@ -19,8 +19,14 @@ import {
setUserAttribute,
addAttributeRule,
createTeamAdmin,
waitForAttributeViewToInclude,
} from '../team_settings/helpers';
/** Unique CPA value so only users this test sets match the rule (avoids clashing with leftover Engineering users on the server). */
function uniqueDepartmentValue(testId: string): string {
return `E2E-${testId}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
test.describe('Channel Settings Modal - Access Control Tab', () => {
test('MM-67326_c1 Access Control tab visible for admin on private channel with ABAC enabled', async ({pw}) => {
await pw.skipIfNoLicense();
@ -62,7 +68,7 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
await channelSettings.close();
});
test('MM-67326_c3 Access Control tab hidden for public channel', async ({pw}) => {
test('MM-67326_c3 Membership Policy tab visible for admin on public channel with ABAC enabled', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableABACConfig(adminClient);
@ -76,8 +82,8 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
const channelSettings = await channelsPage.openChannelSettings();
// * Access Control tab is NOT visible for public channel
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
// * Membership Policy tab is visible on public channels when ABAC is enabled (not group-constrained / not default)
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).toBeVisible();
await channelSettings.close();
});
@ -239,15 +245,28 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const departmentValue = uniqueDepartmentValue('c9');
// # Admin's Department satisfies the rule (self-exclusion check passes)
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
// # Private channel — admin is the creator and only member
const channel = await createPrivateChannel(adminClient, team.id);
// # Target user: in the team, Department=Engineering, NOT yet in the channel
// # Target user: in the team, same Department, NOT yet in the channel
const targetUser = await createTeamAdmin(adminClient, team.id);
await setUserAttribute(adminClient, targetUser.id, 'Department', 'Engineering');
await setUserAttribute(adminClient, targetUser.id, 'Department', departmentValue);
// Save will run validateExpressionAgainstRequester and calculateMembershipChanges,
// both of which query the Postgres materialized AttributeView. The enterprise
// access-control service gates view refreshes to once per ~30s, so the brand-new
// unique CPA value above is not yet visible to CEL queries. Without this wait,
// Save hits the self-exclusion modal (admin appears unmatched against the rule
// they just satisfied via the API) and the confirmation modal never opens.
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
adminUser.id,
targetUser.id,
]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
@ -262,8 +281,11 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add attribute rule: Department == Engineering
await addAttributeRule(tab, page, 'Engineering');
// # Add attribute rule (unique value → preview lists only targetUser to add)
await addAttributeRule(tab, page, departmentValue);
// * Unsaved changes must be committed before save; otherwise handleSave can skip the confirmation path
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 15000});
// # Enable auto-add members
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
@ -449,15 +471,24 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
await enableABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
const departmentValue = uniqueDepartmentValue('c13');
// # Admin satisfies the rule
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
// # Private channel — admin is the only member
const channel = await createPrivateChannel(adminClient, team.id);
// # Target user: in the team, Department=Engineering, NOT yet in the channel
// # Target user: in the team, same Department, NOT yet in the channel
const memberToAdd = await createTeamAdmin(adminClient, team.id);
await setUserAttribute(adminClient, memberToAdd.id, 'Department', 'Engineering');
await setUserAttribute(adminClient, memberToAdd.id, 'Department', departmentValue);
// See c9: wait for the materialized AttributeView to surface admin and
// memberToAdd as matching the freshly-written CPA value before clicking Save.
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
adminUser.id,
memberToAdd.id,
]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
@ -472,8 +503,9 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
await expect(tab).toBeVisible({timeout: 10000});
// # Add rule: Department == Engineering
await addAttributeRule(tab, page, 'Engineering');
// # Add rule (unique value → only memberToAdd appears in "to add" with this server data)
await addAttributeRule(tab, page, departmentValue);
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 15000});
// # Enable auto-add so memberToAdd appears in the "to add" list
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
@ -493,9 +525,6 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
await expect(confirmModal).toContainText('remove 0 current channel members');
// # Click "View users" to open the detailed user list
// Note: the "add N users" count in the summary is not asserted here because leftover
// users from previous test runs may also have Department=Engineering, making the
// count non-deterministic. We verify the specific user in the Allowed tab instead.
await confirmModal.getByRole('button', {name: 'View users'}).click();
// * Allowed tab is visible with at least one user (memberToAdd)
@ -503,7 +532,7 @@ test.describe('Channel Settings Modal - Access Control Tab', () => {
timeout: 5000,
});
// # The Allowed tab is active by default — verify memberToAdd's username is shown
// # The Allowed tab is active by default — verify memberToAdd's username is shown (unique Dept avoids other matches)
await expect(confirmModal).toContainText(memberToAdd.username, {timeout: 5000});
// # Cancel — don't actually apply

View file

@ -0,0 +1,296 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Lean helpers for the public-channel ABAC E2E specs.
*
* Design rules:
* - Reuse the existing primitives in `../team_settings/helpers.ts` for
* channel/policy/user creation and configuration. This file only adds
* thin wrappers that register cleanup as resources are created.
* - All test-created resources are tracked in a `CleanupLedger`. The spec's
* `test.afterEach` drains the ledger so resources are removed whether the
* test passed, failed, or threw mid-flight.
* - Cleanup is best-effort: individual failures are swallowed so one stale
* resource cannot block deletion of the others or mask the test result.
* - Custom profile attributes are scoped per test (unique names + cleanup).
* Tests must not lean on a shared `Department` field the server caps CPA
* fields at 20, and accumulating shared state has historically saturated
* that limit and broken every subsequent run.
*/
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import {newTestPassword, getRandomId} from '@mattermost/playwright-lib';
import {assignChannelsToPolicy, deletePolicy, unassignChannelsFromPolicy} from '../team_settings/helpers';
export type CleanupTask = () => Promise<unknown>;
export class CleanupLedger {
private tasks: CleanupTask[] = [];
add(task: CleanupTask): void {
// LIFO: latest registrations run first so deletions respect dependency
// order (e.g. unassign channels from a policy before deleting the policy).
this.tasks.unshift(task);
}
async drain(): Promise<void> {
const tasks = this.tasks;
this.tasks = [];
for (let i = 0; i < tasks.length; i++) {
try {
await tasks[i]();
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
const stack = e instanceof Error ? e.stack : undefined;
// eslint-disable-next-line no-console
console.error(`CleanupLedger.drain: cleanup task ${i} failed`, message, stack ?? '');
// Swallow — cleanup is best-effort and must not mask the test
// result or stop subsequent cleanups from running.
}
}
}
}
/**
* Issue a server request and surface the response body on non-2xx so the test
* report shows *what* the server rejected, not just "failed". The shipped
* helpers in `team_settings/helpers.ts` swallow these useful in steady
* state, brutal when something legitimately broke (e.g. CPA field-limit
* exhaustion silently masquerading as a "field not found" error).
*/
async function doFetchOrThrow(client: Client4, url: string, init: RequestInit & {body?: string}): Promise<any> {
const response = await fetch(url, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${client.getToken()}`,
...(init.headers || {}),
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`${init.method} ${url} -> ${response.status}: ${text}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}
/**
* Permanently delete a user via the admin API.
* Permanent deletion requires `ServiceSettings.EnableAPIUserDeletion` to be on
* server-side; on test deployments where it is off, the call returns 4xx and
* the cleanup ledger silently swallows it.
*/
export async function permanentDeleteUser(client: Client4, userId: string): Promise<void> {
await (client as any).doFetch(`${client.getBaseRoute()}/users/${userId}?permanent=true`, {
method: 'DELETE',
});
}
/**
* Create a per-test custom profile attribute (text type) with a unique name and
* register cleanup. Returns the field including its server-assigned `id`,
* which callers can plug straight into a CEL expression via
* `user.attributes.id_<id>`.
*
* Avoids the shared `Department` slot used by other ABAC tests: those compete
* for one of 20 server-cap'd CPA slots and any leak (a failed test, an
* abandoned run) cascades into "field not found" or "limit reached" errors
* across the whole suite.
*/
export async function createTrackedAttribute(
client: Client4,
baseName: string,
ledger: CleanupLedger,
): Promise<{id: string; name: string}> {
const name = `${baseName}_${getRandomId()}`;
const field = await doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields`, {
method: 'POST',
body: JSON.stringify({name, type: 'text', attrs: {visibility: 'when_set'}}),
});
ledger.add(() =>
doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields/${field.id}`, {
method: 'DELETE',
}),
);
return {id: field.id, name: field.name};
}
/**
* Set a CPA value on a user, addressing the field by its ID. Avoids the
* field-name-to-ID lookup in the shared `setUserAttribute` helper, which is
* unnecessary indirection now that callers hold the field handle directly.
*/
export async function setUserAttributeById(client: Client4, userId: string, fieldId: string, value: string) {
await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value});
}
/**
* Create a public channel and register cleanup for it.
*/
export async function createTrackedPublicChannel(client: Client4, teamId: string, ledger: CleanupLedger) {
const id = getRandomId();
const channel = await client.createChannel({
team_id: teamId,
name: `pub-${id}`,
display_name: `PUB-${id}`,
type: 'O',
} as any);
ledger.add(() => client.deleteChannel(channel.id));
return channel;
}
/**
* Create a parent ABAC policy with the given CEL rule and assign the given
* channels in one shot. Registers cleanup so the policy (and its inherited
* channel-scope children) gets removed even if the test bails partway.
*
* Why we don't reuse `createParentPolicy` from team_settings/helpers.ts and
* patch afterwards: that helper hardcodes `expression: 'true'`, and a follow-up
* PUT to override the rule fails the server's policy-validation pipeline.
* Stamping the desired expression on the initial create is both simpler and
* matches what the system console actually does.
*/
export async function createPolicyAssignedToChannels(
client: Client4,
name: string,
expression: string,
channelIds: string[],
ledger: CleanupLedger,
) {
const policy: any = await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies`, {
method: 'PUT',
body: JSON.stringify({
id: '',
name,
type: 'parent',
version: 'v0.2',
revision: 0,
rules: [{expression, actions: ['*']}],
}),
});
// Cleanup is self-sufficient: unassign first, then delete. The LIFO drain
// order in the spec runs this BEFORE the channel deletes, so the parent
// still has live child-scope policy rows referencing it — DeleteAccessControlPolicy
// would silently fail and leak the parent. Unassigning explicitly drops
// those children regardless of channel cleanup order.
ledger.add(async () => {
if (channelIds.length > 0) {
try {
await unassignChannelsFromPolicy(client, policy.id, channelIds);
} catch {
// Channels may already be gone (their cleanup tasks may have run
// first), in which case the child policies were auto-removed by
// cleanupChannelAccessControlPolicy. Either way, best-effort.
}
}
await deletePolicy(client, policy.id);
});
if (channelIds.length > 0) {
await assignChannelsToPolicy(client, policy.id, channelIds);
}
return policy;
}
/**
* Flip the auto-add (Active) flag on a channel-scope ABAC policy. Channel-scope
* policies share the channel's ID, so this is keyed by channelId. Active=true
* is what makes the access-control sync job auto-add matching users.
*
* Children inherit the parent's Active flag at assign time, but the parent
* default on a fresh `createPolicyAssignedToChannels` is false flip the
* child here when the test needs auto-add ON.
*/
export async function setChannelPolicyActive(client: Client4, channelId: string, active: boolean): Promise<void> {
await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies/activate`, {
method: 'PUT',
body: JSON.stringify({entries: [{id: channelId, active}]}),
});
}
/**
* Trigger an access-control sync job for a single policy and return the job
* record. The job is queued call `waitForJobCompletion` to block until it
* resolves.
*/
export async function runAccessControlSyncJob(client: Client4, policyId: string): Promise<any> {
return client.createJob({
type: 'access_control_sync' as any,
data: {policy_id: policyId},
});
}
/**
* Poll a job until it reaches a terminal status (success / error / canceled).
* Sync jobs are queued and processed asynchronously; in CI the queue can be
* idle for several seconds before pickup. The default 60s ceiling matches what
* we've seen empirically; callers can extend via `timeoutMs` when running on
* slow servers.
*/
export async function waitForJobCompletion(
client: Client4,
jobId: string,
opts: {timeoutMs?: number; pollIntervalMs?: number} = {},
): Promise<any> {
const timeoutMs = opts.timeoutMs ?? 60_000;
const pollIntervalMs = opts.pollIntervalMs ?? 1_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const job: any = await client.getJob(jobId);
if (job.status === 'success') {
return job;
}
if (job.status === 'error' || job.status === 'canceled') {
const detail = job?.data?.message ?? job?.message ?? job?.error ?? JSON.stringify(job?.data ?? job);
throw new Error(`Job ${jobId} finished with status ${job.status}: ${detail}`);
}
await new Promise((res) => setTimeout(res, pollIntervalMs));
}
throw new Error(`Job ${jobId} did not reach a terminal status within ${timeoutMs}ms`);
}
/**
* Create a user, add them to a team, optionally set a single attribute by ID,
* and bypass the tutorial / onboarding so tests can `pw.testBrowser.login()`
* straight into the channels view. Cleanup is registered for the user.
*/
export async function createTrackedTeamMember(
adminClient: Client4,
teamId: string,
attribute: {fieldId: string; value: string} | undefined,
ledger: CleanupLedger,
): Promise<UserProfile & {password: string}> {
const id = getRandomId();
const username = `pub${id}`.toLowerCase();
const password = newTestPassword();
const user = await adminClient.createUser(
{email: `${username}@sample.mattermost.com`, username, password} as UserProfile & {password: string},
'',
'',
);
ledger.add(() => permanentDeleteUser(adminClient, user.id));
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);
if (attribute) {
await setUserAttributeById(adminClient, user.id, attribute.fieldId, attribute.value);
}
// Attach the password back to the user object so pw.testBrowser.login() can
// authenticate — the API response does not include it.
return {...user, password};
}

View file

@ -16,6 +16,20 @@ export async function enableABACConfig(client: Client4) {
});
}
/**
* Enable `ServiceSettings.EnableAPIUserDeletion` so test cleanup can permanently
* delete the throwaway users it created. Without this, test cleanup spams the
* console with "Permanent user deletion feature is not enabled" errors and leaks
* disabled-but-not-deleted test users on the server.
*/
export async function enableAPIUserDeletion(client: Client4) {
await client.patchConfig({
ServiceSettings: {
EnableAPIUserDeletion: true,
},
});
}
export async function ensureDepartmentAttribute(client: Client4) {
let fields: any[] = [];
try {
@ -130,6 +144,57 @@ export async function setUserAttribute(adminClient: Client4, userId: string, fie
await adminClient.updateUserCustomProfileAttributesValues(userId, {[field.id]: value});
}
/**
* Wait until all `expectedUserIds` show up as matching `expression` via the
* access-control CEL test endpoint.
*
* Why this exists: ABAC queries (validateExpressionAgainstRequester,
* calculateMembershipChanges) read from a Postgres materialized view
* (`AttributeView`). The enterprise access-control service refreshes that view
* at most once every 30 seconds so freshly-written CPA values are not visible
* until the next refresh tick. A test that writes a brand-new CPA value and
* then immediately clicks "Save" on a rule referencing that value will hit
* `requester_matches: false` and surface the self-exclusion modal instead of
* the membership-changes confirmation modal.
*
* Polling forces wall-clock time to advance; the next CEL query after the 30s
* gate elapses will refresh the view and pick up the new attribute values.
* Default timeout 45s = 30s gate + 15s headroom.
*/
export async function waitForAttributeViewToInclude(
adminClient: Client4,
expression: string,
expectedUserIds: string[],
timeoutMs = 45_000,
pollIntervalMs = 1_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastSeen = new Set<string>();
while (Date.now() < deadline) {
const response: any = await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/access_control_policies/cel/test`,
{
method: 'post',
body: JSON.stringify({
expression,
term: '',
after: '',
limit: 1000,
}),
},
);
lastSeen = new Set<string>((response?.users || []).map((u: any) => u.id));
if (expectedUserIds.every((id) => lastSeen.has(id))) {
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
const missing = expectedUserIds.filter((id) => !lastSeen.has(id));
throw new Error(
`AttributeView did not include users [${missing.join(', ')}] for expression "${expression}" within ${timeoutMs}ms`,
);
}
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);

View file

@ -127,9 +127,7 @@ test.describe('ABAC Policies - Channel Integration', () => {
await systemConsolePage.page.waitForTimeout(500);
// Select policy in modal
const modal = systemConsolePage.page
.locator('[role="dialog"]')
.filter({hasText: 'Select an Access Control Policy'});
const modal = systemConsolePage.page.locator('[role="dialog"]').filter({hasText: 'Select a Membership Policy'});
await modal.waitFor({state: 'visible', timeout: 5000});
const modalSearch = modal.locator('[data-testid="searchInput"]');

View file

@ -870,7 +870,6 @@ func searchChannelsForAccessControlPolicy(c *Context, w http.ResponseWriter, r *
opts := model.ChannelSearchOpts{
Deleted: props.Deleted,
IncludeDeleted: props.IncludeDeleted,
Private: true,
ExcludeGroupConstrained: true,
TeamIds: teamIds,
ParentAccessControlPolicyId: policyID,

View file

@ -5,6 +5,7 @@ package api4
import (
"context"
"net/http"
"os"
"testing"
@ -326,6 +327,44 @@ func TestCreateAccessControlPolicy(t *testing.T) {
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("system admin cannot create a channel-scope policy on a team default channel", func(t *testing.T) {
// The api4 handler short-circuits validation for system admins, so the
// eligibility guard must live in the app layer. This test rides that
// path: SystemAdmin → handler skips ValidateChannelAccessControlPolicyCreation
// → CreateOrUpdateAccessControlPolicy must still reject default channels.
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
// SavePolicy should never be reached — the guard rejects before that.
mockAccessControlService.On("SavePolicy", mock.Anything, mock.Anything).
Return(nil, model.NewAppError("SavePolicy", "should.not.be.called", nil, "", http.StatusInternalServerError)).Maybe()
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
require.Nil(t, appErr)
defaultChannelPolicy := &model.AccessControlPolicy{
ID: townSquare.Id,
Type: model.AccessControlPolicyTypeChannel,
Name: "default-channel-policy",
Version: model.AccessControlPolicyVersionV0_3,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{Actions: []string{"membership"}, Expression: "true"},
},
}
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), defaultChannelPolicy)
require.Error(t, err, "default channels must not accept ABAC policies, even for system admins")
CheckBadRequestStatus(t, resp)
mockAccessControlService.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
})
}
func TestGetAccessControlPolicy(t *testing.T) {
@ -1066,20 +1105,113 @@ func TestSearchChannelsForAccessControlPolicy(t *testing.T) {
require.NotNil(t, channelsResp)
})
t.Run("public channels assigned to the policy appear in search results", func(t *testing.T) {
setupLicenseAndABAC(t)
parentPolicy := newSamplePolicy()
savedParent, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, parentPolicy)
require.NoError(t, err)
t.Cleanup(func() {
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, savedParent.ID)
})
// Public channels were previously hidden from this search by a hardcoded
// Private: true filter. Removing that filter is the whole point of the
// public-channel ABAC change; this test prevents regressions if someone
// re-introduces the filter in a future cleanup.
publicChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, th.BasicTeam.Id)
childPolicy := &model.AccessControlPolicy{
ID: publicChannel.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_3,
Revision: 1,
Imports: []string{savedParent.ID},
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"membership"},
},
},
}
_, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, childPolicy)
require.NoError(t, err)
t.Cleanup(func() {
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, publicChannel.Id)
})
channelsResp, resp, err := th.SystemAdminClient.SearchChannelsForAccessControlPolicy(
context.Background(), savedParent.ID,
model.ChannelSearch{TeamIds: []string{th.BasicTeam.Id}})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, channelsResp)
channelsByID := make(map[string]*model.ChannelWithTeamData, len(channelsResp.Channels))
for _, ch := range channelsResp.Channels {
channelsByID[ch.Id] = ch
}
require.Contains(t, channelsByID, publicChannel.Id,
"public channel assigned to the policy should appear in search results")
require.Equal(t, model.ChannelTypeOpen, channelsByID[publicChannel.Id].Type,
"expected the matched channel to be public")
// Same fetch via the team-admin path used by the team-settings policy
// editor (?team_id=…). The team-scoped branch must also surface public
// channels — there's no longer any reason to filter them out.
th.LinkUserToTeam(t, th.TeamAdminUser, th.BasicTeam)
th.UpdateUserToTeamAdmin(t, th.TeamAdminUser, th.BasicTeam)
th.LoginTeamAdmin(t)
t.Cleanup(func() { th.LoginBasic(t) })
teamScopedResp, resp, err := th.Client.SearchChannelsForAccessControlPolicyForTeam(
context.Background(), savedParent.ID, th.BasicTeam.Id, model.ChannelSearch{})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, teamScopedResp)
teamChannelsByID := make(map[string]*model.ChannelWithTeamData, len(teamScopedResp.Channels))
for _, ch := range teamScopedResp.Channels {
teamChannelsByID[ch.Id] = ch
}
require.Contains(t, teamChannelsByID, publicChannel.Id,
"team-admin policy editor must also surface public channels assigned to the policy")
})
t.Run("team admin body TeamIds forced to authorized team", func(t *testing.T) {
setupLicenseAndABAC(t)
policy := newSamplePolicy()
savedPolicy, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
parentPolicy := newSamplePolicy()
savedParent, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, parentPolicy)
require.NoError(t, err)
defer func() {
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, savedPolicy.ID)
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, savedParent.ID)
}()
// Two teams, each with one private channel. The BasicTeam channel is
// linked to the parent policy so it shows up in the search; the
// otherTeam channel is unrelated. The override-correctness test then
// proves both that the BasicTeam channel IS returned (the search
// isn't trivially empty) and that the otherTeam channel is NOT
// returned even though the request body asked for it explicitly.
basicTeamChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypePrivate, th.BasicTeam.Id)
basicTeamChild := &model.AccessControlPolicy{
ID: basicTeamChannel.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_3,
Revision: 1,
Imports: []string{savedParent.ID},
Rules: []model.AccessControlPolicyRule{
{Expression: "user.attributes.team == 'engineering'", Actions: []string{"membership"}},
},
}
_, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, basicTeamChild)
require.NoError(t, err)
defer func() {
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, basicTeamChannel.Id)
}()
// Create a second team with a private channel
otherTeam := th.CreateTeam(t)
otherChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypePrivate, otherTeam.Id)
_ = otherChannel
th.LinkUserToTeam(t, th.TeamAdminUser, th.BasicTeam)
th.UpdateUserToTeamAdmin(t, th.TeamAdminUser, th.BasicTeam)
@ -1089,19 +1221,26 @@ func TestSearchChannelsForAccessControlPolicy(t *testing.T) {
// Attempt to search with body TeamIds pointing to a different team.
// The authZ is against BasicTeam (via team_id query param), but the
// body tries to query otherTeam's channels. The fix should force
// body tries to query otherTeam's channels. The handler should force
// TeamIds to BasicTeam.Id regardless of what the body says.
channelsResp, resp, err := th.Client.SearchChannelsForAccessControlPolicyForTeam(
context.Background(), savedPolicy.ID, th.BasicTeam.Id,
context.Background(), savedParent.ID, th.BasicTeam.Id,
model.ChannelSearch{TeamIds: []string{otherTeam.Id}})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, channelsResp)
// None of the returned channels should belong to the other team
channelsByID := make(map[string]*model.ChannelWithTeamData, len(channelsResp.Channels))
for _, ch := range channelsResp.Channels {
channelsByID[ch.Id] = ch
}
require.Contains(t, channelsByID, basicTeamChannel.Id,
"BasicTeam channel must surface — proves the search is exercised, not just trivially empty")
require.NotContains(t, channelsByID, otherChannel.Id,
"otherTeam channel must NOT surface even though body asked for it — proves the team_id query param overrides body TeamIds")
for _, ch := range channelsResp.Channels {
require.Equal(t, th.BasicTeam.Id, ch.TeamId,
"team admin should only see channels from the authorized team, got channel %s from team %s", ch.Id, ch.TeamId)
"team admin must only see channels from the authorized team, got channel %s from team %s", ch.Id, ch.TeamId)
}
})

View file

@ -32,6 +32,7 @@ func (api *API) InitChannel() {
api.BaseRoutes.ChannelsForTeam.Handle("", api.APISessionRequired(getPublicChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/deleted", api.APISessionRequired(getDeletedChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/private", api.APISessionRequired(getPrivateChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/recommended", api.APISessionRequired(getRecommendedChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/ids", api.APISessionRequired(getPublicChannelsByIdsForTeam)).Methods(http.MethodPost)
api.BaseRoutes.ChannelsForTeam.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchChannelsForTeam)).Methods(http.MethodPost)
api.BaseRoutes.ChannelsForTeam.Handle("/autocomplete", api.APISessionRequired(autocompleteChannelsForTeam)).Methods(http.MethodGet)
@ -1026,6 +1027,31 @@ func getPublicChannelsForTeam(c *Context, w http.ResponseWriter, r *http.Request
}
}
// getRecommendedChannelsForTeam returns public channels in the team with an
// active ABAC policy that the requesting user's attributes satisfy. The list
// is consumed by the "Recommended channels" feature in the browse UI.
func getRecommendedChannelsForTeam(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionListTeamChannels) {
c.SetPermissionError(model.PermissionListTeamChannels)
return
}
channels, err := c.App.GetRecommendedPublicChannelsForUser(c.AppContext, c.AppContext.Session().UserId, c.Params.TeamId)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(channels); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getDeletedChannelsForTeam(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {

View file

@ -2400,6 +2400,127 @@ func TestGetPublicChannelsForTeam(t *testing.T) {
})
}
func TestGetRecommendedChannelsForTeam(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("without enterprise license ABAC is disabled so the endpoint returns an empty list", func(t *testing.T) {
// Be explicit about the license precondition so this subtest is
// deterministic even if a parallel test elsewhere installed one.
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
resp, err := th.Client.DoAPIGet(context.Background(), "/teams/"+th.BasicTeam.Id+"/channels/recommended", "")
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var channels []*model.Channel
require.NoError(t, json.NewDecoder(resp.Body).Decode(&channels))
require.Empty(t, channels)
})
t.Run("user must be on the team", func(t *testing.T) {
otherTeamUser := th.CreateUser(t)
client := th.CreateClient()
_, _, err := client.Login(context.Background(), otherTeamUser.Email, otherTeamUser.Password)
require.NoError(t, err)
resp, err := client.DoAPIGet(context.Background(), "/teams/"+th.BasicTeam.Id+"/channels/recommended", "")
require.Error(t, err)
// resp can be nil on transport errors; only defer Close when we
// actually got an HTTP response back so we can assert the status.
require.NotNil(t, resp)
defer resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("returns policy-enforced channels the requester matches under enterprise license", func(t *testing.T) {
// License + ABAC config gate the endpoint; without these it short-circuits
// to an empty list (covered by the no-license subtest above).
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
t.Cleanup(func() { _ = th.App.Srv().RemoveLicense() })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
// Wire a mock ABAC service that allows the requester for `included`
// and denies for `excluded`. This pins the api4 layer's responsibility
// (routing, permissions, response shape) to a deterministic policy
// outcome — the underlying CEL evaluation has its own coverage at the
// app layer in TestGetRecommendedPublicChannelsForUser.
mockACS := &einterfacesmocks.AccessControlServiceInterface{}
originalACS := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = originalACS })
// PermanentDeleteChannel during cleanup calls DeletePolicy on the ACS.
mockACS.On("DeletePolicy", mock.Anything, mock.AnythingOfType("string")).
Return((*model.AppError)(nil)).Maybe()
included, _, _ := th.SystemAdminClient.CreateChannel(context.Background(), &model.Channel{
TeamId: th.BasicTeam.Id,
Type: model.ChannelTypeOpen,
Name: "abac-recommended-" + model.NewId(),
DisplayName: "ABAC Recommended",
})
require.NotNil(t, included)
t.Cleanup(func() {
_ = th.App.PermanentDeleteChannel(th.Context, included)
})
excluded, _, _ := th.SystemAdminClient.CreateChannel(context.Background(), &model.Channel{
TeamId: th.BasicTeam.Id,
Type: model.ChannelTypeOpen,
Name: "abac-excluded-" + model.NewId(),
DisplayName: "ABAC Excluded",
})
require.NotNil(t, excluded)
t.Cleanup(func() {
_ = th.App.PermanentDeleteChannel(th.Context, excluded)
})
// Stamp channel-scope policy rows so SearchAllChannels picks them up
// as PolicyEnforced=true. The expressions are placeholders — the mock
// ACS short-circuits evaluation below.
for _, ch := range []*model.Channel{included, excluded} {
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, &model.AccessControlPolicy{
ID: ch.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Active: true,
Rules: []model.AccessControlPolicyRule{{Actions: []string{"membership"}, Expression: "true"}},
})
require.NoError(t, err)
}
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == included.Id
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == excluded.Id
})).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
resp, err := th.Client.DoAPIGet(context.Background(), "/teams/"+th.BasicTeam.Id+"/channels/recommended", "")
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var channels []*model.Channel
require.NoError(t, json.NewDecoder(resp.Body).Decode(&channels))
ids := make(map[string]bool, len(channels))
for _, ch := range channels {
ids[ch.Id] = true
}
require.True(t, ids[included.Id], "policy-allowed channel must be returned")
require.False(t, ids[excluded.Id], "policy-denied channel must be filtered out")
})
}
func TestGetPublicChannelsByIdsForTeam(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)

View file

@ -943,8 +943,33 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if ok, _ := c.App.ChannelAccessControlled(c.AppContext, notInChannelId); ok {
// Get cursor_id from query parameters for cursor-based pagination
// ABAC filtering is mandatory for private policy-enforced channels (hard gate).
// For public policy-enforced channels, ABAC is advisory — only apply the
// filter when the caller explicitly asks via abac_match_only=true so callers
// like the invite modal can fetch all team members and annotate the matching
// subset without the list being narrowed for them.
//
// Surface ChannelAccessControlled errors instead of silently swallowing
// them — a transient store / license read failure here would otherwise
// fall through to the unfiltered path and could expose users a hard-gated
// private channel was configured to hide.
abacMatchOnly, _ := strconv.ParseBool(r.URL.Query().Get("abac_match_only"))
useAbacFilter := false
enforced, enforcedErr := c.App.ChannelAccessControlled(c.AppContext, notInChannelId)
if enforcedErr != nil {
c.Err = enforcedErr
return
}
if enforced {
ch, chErr := c.App.GetChannel(c.AppContext, notInChannelId)
if chErr != nil {
c.Err = chErr
return
}
useAbacFilter = ch.Type == model.ChannelTypePrivate || abacMatchOnly
}
if useAbacFilter {
cursorId := r.URL.Query().Get("cursor_id")
profiles, appErr = c.App.GetUsersNotInAbacChannel(c.AppContext, inTeamId, notInChannelId, groupConstrainedBool, cursorId, c.Params.PerPage, c.IsSystemAdmin(), restrictions)
} else {

View file

@ -3534,6 +3534,126 @@ func TestGetUsersNotInChannel(t *testing.T) {
require.NoError(t, err)
}
// TestGetUsersNotInChannelAbacMatchOnly exercises the dispatcher in
// getUsers that decides whether to apply ABAC filtering based on the
// channel type and the abac_match_only query parameter. The underlying
// ABAC store path (GetUsersNotInAbacChannel) has its own coverage in
// app/user_test.go; here we only assert the dispatch wiring.
func TestGetUsersNotInChannelAbacMatchOnly(t *testing.T) {
th := Setup(t).InitBasic(t)
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
})
teamId := th.BasicTeam.Id
user1 := th.CreateUser(t)
user2 := th.CreateUser(t)
th.LinkUserToTeam(t, user1, th.BasicTeam)
th.LinkUserToTeam(t, user2, th.BasicTeam)
privateChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypePrivate, th.BasicTeam.Id)
publicChannel := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, th.BasicTeam.Id)
// Add BasicUser as a member so th.Client (a regular user) has
// PermissionReadChannel on the target — the endpoint isn't sys-admin gated,
// it just requires read access on the channel. Membership is added before
// the ABAC policy is saved so the AddUserToChannel path doesn't go through
// the policy gate (which is unrelated to what we're testing here).
th.AddUserToChannel(t, th.BasicUser, privateChannel)
th.AddUserToChannel(t, th.BasicUser, publicChannel)
saveChannelPolicy := func(channelID string) {
policy := &model.AccessControlPolicy{
ID: channelID,
Type: model.AccessControlPolicyTypeChannel,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_2,
Active: true,
Rules: []model.AccessControlPolicyRule{
{Actions: []string{"membership"}, Expression: "true"},
},
}
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
require.NoError(t, err)
// PolicyEnforced is computed at channel-fetch time and cached. Adding
// BasicUser as a member above populated the cache with PolicyEnforced=false;
// invalidate so ChannelAccessControlled (the dispatcher's gate) sees the
// freshly-saved policy on subsequent reads.
th.App.Srv().Store().Channel().InvalidateChannel(channelID)
t.Cleanup(func() {
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channelID)
})
}
saveChannelPolicy(privateChannel.Id)
saveChannelPolicy(publicChannel.Id)
mockACS := &mocks.AccessControlServiceInterface{}
originalACS := th.App.Srv().Channels().AccessControl
th.App.Srv().Channels().AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = originalACS })
// QueryUsersForResource is the ABAC path; whenever it is hit, we return
// only user1 — that's the signal the dispatcher routed to the filtered
// branch. user2 only ever appears via the unfiltered store path.
//
// The third argument is pinned to the actual action constant
// (`AccessControlPolicyActionMembership`) so the mock matches the real
// call site in App.GetUsersNotInAbacChannel — using `"*"` here would
// silently never match and the whole test would PASS by accident on the
// fall-through path.
mockACS.On("QueryUsersForResource",
mock.Anything,
mock.AnythingOfType("string"),
model.AccessControlPolicyActionMembership,
mock.Anything,
).Return([]*model.User{user1}, int64(1), nil).Maybe()
listUsers := func(t *testing.T, channelID string, abacMatchOnly bool) []string {
t.Helper()
query := url.Values{}
query.Set("in_team", teamId)
query.Set("not_in_channel", channelID)
query.Set("page", "0")
query.Set("per_page", "200")
if abacMatchOnly {
query.Set("abac_match_only", "true")
}
resp, err := th.Client.DoAPIGet(context.Background(), "/users?"+query.Encode(), "")
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
var users []*model.User
require.NoError(t, json.NewDecoder(resp.Body).Decode(&users))
ids := make([]string, 0, len(users))
for _, u := range users {
ids = append(ids, u.Id)
}
return ids
}
t.Run("private policy channel: ABAC filter applied without flag", func(t *testing.T) {
ids := listUsers(t, privateChannel.Id, false)
require.Contains(t, ids, user1.Id)
require.NotContains(t, ids, user2.Id, "private policy channel must hard-gate non-matching users")
})
t.Run("public policy channel: full list returned without flag", func(t *testing.T) {
ids := listUsers(t, publicChannel.Id, false)
require.Contains(t, ids, user1.Id)
require.Contains(t, ids, user2.Id, "public policy channel without abac_match_only must return non-matching users so callers can annotate them")
})
t.Run("public policy channel: ABAC filter applied with abac_match_only=true", func(t *testing.T) {
ids := listUsers(t, publicChannel.Id, true)
require.Contains(t, ids, user1.Id)
require.NotContains(t, ids, user2.Id, "abac_match_only=true on a public policy channel must drop non-matching users")
})
}
func TestGetUsersInGroup(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)

View file

@ -86,6 +86,23 @@ func (a *App) CreateOrUpdateAccessControlPolicy(rctx request.CTX, policy *model.
policy.ID = model.NewId()
}
// Channel-scope policies are pinned to a single channel by ID. Validate
// channel eligibility here (default / DM / GM / group-constrained / shared
// channels are ineligible) so this guard protects all callers — including
// system admins, whose request goes through the api4 handler's permission
// fast-path that skips the per-channel ValidateChannelAccessControlPolicyCreation
// check, and the parent-policy AssignAccessControlPolicyToChannels flow,
// which validates eligibility there but bypasses this entry point.
if policy.Type == model.AccessControlPolicyTypeChannel {
channel, appErr := a.GetChannel(rctx, policy.ID)
if appErr != nil {
return nil, appErr
}
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
return nil, appErr
}
}
policy.Version = model.AccessControlPolicyVersionV0_3
for i, rule := range policy.Rules {
for j, action := range rule.Actions {
@ -193,7 +210,7 @@ func (a *App) AssignAccessControlPolicyToChannels(rctx request.CTX, parentID str
policies := make([]*model.AccessControlPolicy, 0, len(channelIDs))
for _, channel := range channels {
if appErr := ValidateChannelEligibilityForAccessControl(channel); appErr != nil {
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
return nil, appErr
}
@ -464,12 +481,13 @@ func (a *App) publishChannelPolicyEnforcedUpdate(rctx request.CTX, channelID str
}
// ValidateChannelEligibilityForAccessControl checks that a channel is eligible for
// access control policy assignment: must be private, not group-constrained, not shared.
func ValidateChannelEligibilityForAccessControl(channel *model.Channel) *model.AppError {
if channel.Type != model.ChannelTypePrivate {
// access control policy assignment: must be public or private (DM/GM excluded),
// not group-constrained, not shared, and not a team default channel (e.g. town-square).
func (a *App) ValidateChannelEligibilityForAccessControl(rctx request.CTX, channel *model.Channel) *model.AppError {
if channel.Type != model.ChannelTypePrivate && channel.Type != model.ChannelTypeOpen {
return model.NewAppError("ValidateChannelEligibilityForAccessControl",
"app.pap.access_control.channel_not_private",
nil, "Channel is not of type private", http.StatusBadRequest)
"app.pap.access_control.channel_type_not_supported",
nil, "Policies can only be applied to public or private channels", http.StatusBadRequest)
}
if channel.IsGroupConstrained() {
@ -484,6 +502,12 @@ func ValidateChannelEligibilityForAccessControl(channel *model.Channel) *model.A
nil, "Channel is shared", http.StatusBadRequest)
}
if slices.Contains(a.DefaultChannelNames(rctx), channel.Name) {
return model.NewAppError("ValidateChannelEligibilityForAccessControl",
"app.pap.access_control.channel_default",
nil, "Channel is a team default channel", http.StatusBadRequest)
}
return nil
}
@ -500,7 +524,7 @@ func (a *App) ValidateChannelAccessControlPermission(rctx request.CTX, userID, c
return model.NewAppError("ValidateChannelAccessControlPermission", "app.pap.access_control.insufficient_channel_permissions", nil, "user_id="+userID+" channel_id="+channelID, http.StatusForbidden)
}
if appErr := ValidateChannelEligibilityForAccessControl(channel); appErr != nil {
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
return appErr
}

View file

@ -124,7 +124,13 @@ func TestCreateOrUpdateAccessControlPolicy(t *testing.T) {
// publishChannelPolicyEnforcedUpdate is expected to invalidate the
// channel cache and reload the channel for the WS payload.
mockChannelStore.On("InvalidateChannel", channelID).Once()
mockChannelStore.On("Get", channelID, true).Return(&model.Channel{Id: channelID, Type: model.ChannelTypePrivate}, nil).Once()
// Channel().Get is now hit twice during a successful save:
// 1. ValidateChannelEligibilityForAccessControl loads the channel
// to enforce the default / DM / GM / group-constrained / shared
// eligibility rules before SavePolicy.
// 2. publishChannelPolicyEnforcedUpdate reloads it after save to
// build the WS payload.
mockChannelStore.On("Get", channelID, true).Return(&model.Channel{Id: channelID, Type: model.ChannelTypePrivate}, nil).Twice()
mockAccessControl := &mocks.AccessControlServiceInterface{}
thMock.App.Srv().ch.AccessControl = mockAccessControl
@ -139,6 +145,7 @@ func TestCreateOrUpdateAccessControlPolicy(t *testing.T) {
mockAccessControl.AssertExpectations(t)
mockChannelStore.AssertCalled(t, "InvalidateChannel", channelID)
mockChannelStore.AssertCalled(t, "Get", channelID, true)
mockChannelStore.AssertExpectations(t)
})
t.Run("Parent-type policy does not broadcast channel-only update", func(t *testing.T) {
@ -534,18 +541,23 @@ func TestAssignAccessControlPolicyToChannels(t *testing.T) {
t.Run("Error saving policy", func(t *testing.T) {
ch := th.CreatePrivateChannel(t, th.BasicTeam)
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
mockAccessControl.On("GetPolicy", th.Context, parentID).Return(parentPolicy, nil)
mockAccessControl.On("GetPolicy", th.Context, ch.Id).Return(parentPolicy, nil)
mockAccessControl.On("SavePolicy", th.Context, mock.Anything).Return(nil, model.NewAppError("SavePolicy", "error", nil, "save error", http.StatusInternalServerError))
t.Cleanup(func() {
appErr := th.App.PermanentDeleteChannel(th.Context, ch)
require.Nil(t, appErr)
})
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
// Clear the mock before the channel cleanup runs (LIFO: this
// cleanup is registered after the channel cleanup so it runs
// first), so PermanentDeleteChannel's cleanupChannelAccessControlPolicy
// is a no-op and doesn't hit an unmocked DeletePolicy.
t.Cleanup(func() { th.App.Srv().ch.AccessControl = nil })
mockAccessControl.On("GetPolicy", th.Context, parentID).Return(parentPolicy, nil)
mockAccessControl.On("GetPolicy", th.Context, ch.Id).Return(parentPolicy, nil)
mockAccessControl.On("SavePolicy", th.Context, mock.Anything).Return(nil, model.NewAppError("SavePolicy", "error", nil, "save error", http.StatusInternalServerError))
policies, err := th.App.AssignAccessControlPolicyToChannels(th.Context, parentID, []string{ch.Id})
require.NotNil(t, err)
require.Empty(t, policies)
@ -572,33 +584,33 @@ func TestAssignAccessControlPolicyToChannels(t *testing.T) {
assert.Equal(t, "app.pap.assign_access_control_policy_to_channels.app_error", err.Id)
})
t.Run("Channel is not private", func(t *testing.T) {
t.Run("Default channel is not supported", func(t *testing.T) {
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
mockAccessControl.On("GetPolicy", th.Context, parentID).Return(&model.AccessControlPolicy{Type: model.AccessControlPolicyTypeParent}, nil)
// Create a public channel
publicChannel := th.CreateChannel(t, th.BasicTeam)
t.Cleanup(func() {
appErr := th.App.PermanentDeleteChannel(th.Context, publicChannel)
require.Nil(t, appErr)
})
policies, err := th.App.AssignAccessControlPolicyToChannels(th.Context, parentID, []string{publicChannel.Id})
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
require.Nil(t, appErr)
policies, err := th.App.AssignAccessControlPolicyToChannels(th.Context, parentID, []string{townSquare.Id})
require.NotNil(t, err)
assert.Nil(t, policies)
assert.Contains(t, err.Error(), "Channel is not of type private")
assert.Equal(t, "app.pap.access_control.channel_default", err.Id)
})
t.Run("Channel is shared", func(t *testing.T) {
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
mockAccessControl.On("GetPolicy", th.Context, parentID).Return(&model.AccessControlPolicy{Type: model.AccessControlPolicyTypeParent}, nil)
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
t.Cleanup(func() {
appErr := th.App.PermanentDeleteChannel(th.Context, privateChannel)
require.Nil(t, appErr)
})
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
t.Cleanup(func() { th.App.Srv().ch.AccessControl = nil })
mockAccessControl.On("GetPolicy", th.Context, parentID).Return(&model.AccessControlPolicy{Type: model.AccessControlPolicyTypeParent}, nil)
privateChannel.Shared = model.NewPointer(true)
_, err := th.App.Srv().Store().Channel().Update(th.Context, privateChannel)
require.NoError(t, err)
@ -641,6 +653,8 @@ func TestAssignAccessControlPolicyToChannels(t *testing.T) {
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
t.Cleanup(func() { th.App.Srv().ch.AccessControl = nil })
mockAccessControl.On("GetPolicy", th.Context, parentID).Return(parentPolicy, nil)
mockAccessControl.On("GetPolicy", th.Context, ch1.Id).Return(nil, nil)
mockAccessControl.On("GetPolicy", th.Context, ch2.Id).Return(nil, nil)
@ -656,6 +670,215 @@ func TestAssignAccessControlPolicyToChannels(t *testing.T) {
})
}
func TestChannelDeleteCleansUpAccessControlPolicy(t *testing.T) {
th := Setup(t).InitBasic(t)
// Wire up a mock ACS whose DeletePolicy writes through to the store, so the
// cleanup path exercised by DeleteChannel/PermanentDeleteChannel actually
// removes the row. Without this, cleanupChannelAccessControlPolicy is a
// no-op when the enterprise service is not registered.
mockACS := &mocks.AccessControlServiceInterface{}
originalACS := th.App.Srv().ch.AccessControl
th.App.Srv().ch.AccessControl = mockACS
t.Cleanup(func() {
th.App.Srv().ch.AccessControl = originalACS
})
mockACS.On("DeletePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).
Return(func(rctx request.CTX, id string) *model.AppError {
if err := th.App.Srv().Store().AccessControlPolicy().Delete(rctx, id); err != nil {
return model.NewAppError("DeletePolicy", "test.delete", nil, err.Error(), http.StatusInternalServerError)
}
return nil
}).Maybe()
saveChildPolicy := func(t *testing.T, channelID string) {
t.Helper()
policy := &model.AccessControlPolicy{
ID: channelID,
Type: model.AccessControlPolicyTypeChannel,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_2,
Active: true,
Rules: []model.AccessControlPolicyRule{
{Actions: []string{"membership"}, Expression: "true"},
},
}
saved, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
require.NoError(t, err)
require.NotNil(t, saved)
}
t.Run("Archiving a channel deletes its channel-scope policy", func(t *testing.T) {
ch := th.CreatePrivateChannel(t, th.BasicTeam)
saveChildPolicy(t, ch.Id)
// Sanity: policy exists before archive.
fetched, err := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, ch.Id)
require.NoError(t, err)
require.NotNil(t, fetched)
// Reload via GetChannel without invalidating the cache. The channel
// was created before the policy was saved directly to the store, so
// the cached channel still reports PolicyEnforced=false. Cleanup must
// still remove the orphan policy — it no longer trusts the stale
// cached flag.
reloaded, appErr := th.App.GetChannel(th.Context, ch.Id)
require.Nil(t, appErr)
appErr = th.App.DeleteChannel(th.Context, reloaded, th.BasicUser.Id)
require.Nil(t, appErr)
_, err = th.App.Srv().Store().AccessControlPolicy().Get(th.Context, ch.Id)
require.Error(t, err, "channel-scope policy should be removed when the channel is archived")
})
t.Run("Permanently deleting a channel deletes its channel-scope policy", func(t *testing.T) {
ch := th.CreatePrivateChannel(t, th.BasicTeam)
saveChildPolicy(t, ch.Id)
reloaded, appErr := th.App.GetChannel(th.Context, ch.Id)
require.Nil(t, appErr)
appErr = th.App.PermanentDeleteChannel(th.Context, reloaded)
require.Nil(t, appErr)
_, err := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, ch.Id)
require.Error(t, err, "channel-scope policy should be removed when the channel is permanently deleted")
})
t.Run("Archiving a channel with no policy still succeeds", func(t *testing.T) {
ch := th.CreatePrivateChannel(t, th.BasicTeam)
t.Cleanup(func() {
_ = th.App.PermanentDeleteChannel(th.Context, ch)
})
reloaded, appErr := th.App.GetChannel(th.Context, ch.Id)
require.Nil(t, appErr)
// cleanupChannelAccessControlPolicy intentionally calls DeletePolicy
// unconditionally when acs is non-nil — DeletePolicy itself is
// expected to be a no-op when no matching row exists.
appErr = th.App.DeleteChannel(th.Context, reloaded, th.BasicUser.Id)
require.Nil(t, appErr)
})
t.Run("Falls back to direct store delete when acs is nil", func(t *testing.T) {
// Swap in a nil acs for the duration of this subtest so the cleanup
// must take the store-level fallback path (e.g. running on Team
// Edition where the enterprise ABAC service is not registered).
th.App.Srv().ch.AccessControl = nil
t.Cleanup(func() { th.App.Srv().ch.AccessControl = mockACS })
ch := th.CreatePrivateChannel(t, th.BasicTeam)
saveChildPolicy(t, ch.Id)
reloaded, appErr := th.App.GetChannel(th.Context, ch.Id)
require.Nil(t, appErr)
appErr = th.App.PermanentDeleteChannel(th.Context, reloaded)
require.Nil(t, appErr)
_, err := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, ch.Id)
require.Error(t, err, "policy should be removed via the store-level fallback when acs is nil")
})
t.Run("Falls back to direct store delete when acs reports NotImplemented", func(t *testing.T) {
// Replace mockACS with one that always reports the operation as
// unimplemented (e.g. license-gated build of the enterprise layer);
// cleanup must still drop the orphan row through the store fallback.
notImplementedACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = notImplementedACS
t.Cleanup(func() { th.App.Srv().ch.AccessControl = mockACS })
notImplementedACS.On("DeletePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).
Return(model.NewAppError("DeletePolicy", "app.pap.not_initialized", nil, "PAP not initialized", http.StatusNotImplemented)).Once()
ch := th.CreatePrivateChannel(t, th.BasicTeam)
saveChildPolicy(t, ch.Id)
reloaded, appErr := th.App.GetChannel(th.Context, ch.Id)
require.Nil(t, appErr)
appErr = th.App.PermanentDeleteChannel(th.Context, reloaded)
require.Nil(t, appErr)
notImplementedACS.AssertCalled(t, "DeletePolicy", mock.AnythingOfType("*request.Context"), ch.Id)
notImplementedACS.AssertExpectations(t)
_, err := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, ch.Id)
require.Error(t, err, "policy should be removed via the store-level fallback when acs reports NotImplemented")
})
}
func TestUpdateChannelBlocksTypeConversionWhenPolicyEnforced(t *testing.T) {
th := Setup(t).InitBasic(t)
// ABAC + license required for ChannelAccessControlled to report `enforced=true`.
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
t.Cleanup(func() { _ = th.App.Srv().RemoveLicense() })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
mockACS := &mocks.AccessControlServiceInterface{}
originalACS := th.App.Srv().ch.AccessControl
th.App.Srv().ch.AccessControl = mockACS
t.Cleanup(func() { th.App.Srv().ch.AccessControl = originalACS })
mockACS.On("DeletePolicy", mock.Anything, mock.AnythingOfType("string")).Return((*model.AppError)(nil)).Maybe()
stampPolicy := func(t *testing.T, channelID string) {
t.Helper()
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, &model.AccessControlPolicy{
ID: channelID,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Active: true,
Rules: []model.AccessControlPolicyRule{{Actions: []string{"membership"}, Expression: "true"}},
})
require.NoError(t, err)
// Channel().Get is cached; PolicyEnforced is computed at fetch time
// from the AccessControlPolicies table, so an existing cached entry
// would still report `false`. Invalidate so the next Get re-computes.
th.App.Srv().Store().Channel().InvalidateChannel(channelID)
}
t.Run("private → public is rejected when ABAC policy is attached", func(t *testing.T) {
ch := th.CreatePrivateChannel(t, th.BasicTeam)
t.Cleanup(func() { _ = th.App.PermanentDeleteChannel(th.Context, ch) })
stampPolicy(t, ch.Id)
patch := *ch
patch.Type = model.ChannelTypeOpen
_, appErr := th.App.UpdateChannel(th.Context, &patch)
require.NotNil(t, appErr, "type conversion must be blocked while a policy is attached")
require.Equal(t, "api.channel.update_channel.policy_enforced_type_conversion.app_error", appErr.Id)
})
t.Run("public → private is rejected when ABAC policy is attached", func(t *testing.T) {
ch := th.CreateChannel(t, th.BasicTeam)
t.Cleanup(func() { _ = th.App.PermanentDeleteChannel(th.Context, ch) })
stampPolicy(t, ch.Id)
patch := *ch
patch.Type = model.ChannelTypePrivate
_, appErr := th.App.UpdateChannel(th.Context, &patch)
require.NotNil(t, appErr, "type conversion must be blocked in either direction")
require.Equal(t, "api.channel.update_channel.policy_enforced_type_conversion.app_error", appErr.Id)
})
t.Run("non-type updates still succeed on policy-enforced channels", func(t *testing.T) {
ch := th.CreatePrivateChannel(t, th.BasicTeam)
t.Cleanup(func() { _ = th.App.PermanentDeleteChannel(th.Context, ch) })
stampPolicy(t, ch.Id)
patch := *ch
patch.Header = "updated header"
_, appErr := th.App.UpdateChannel(th.Context, &patch)
require.Nil(t, appErr, "non-type updates should pass through; the gate is type-conversion only")
})
}
func TestUnassignPoliciesFromChannels(t *testing.T) {
th := Setup(t).InitBasic(t)
@ -674,8 +897,7 @@ func TestUnassignPoliciesFromChannels(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, parentPolicy)
t.Cleanup(func() {
sErr := th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, parentPolicy.ID)
require.NoError(t, sErr)
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, parentPolicy.ID)
})
ch1 := th.CreatePrivateChannel(t, th.BasicTeam)
@ -689,56 +911,81 @@ func TestUnassignPoliciesFromChannels(t *testing.T) {
require.Nil(t, sErr)
})
childPolicy1 := &model.AccessControlPolicy{
Type: model.AccessControlPolicyTypeChannel,
ID: ch1.Id,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_2,
}
appErrInherit1 := childPolicy1.Inherit(parentPolicy)
require.Nil(t, appErrInherit1)
childPolicy1, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, childPolicy1)
require.NoError(t, err)
require.NotNil(t, childPolicy1)
// Clear any lingering AccessControl mock before per-channel cleanups run,
// so PermanentDeleteChannel's cleanupChannelAccessControlPolicy uses the
// store fallback (or no-ops) during teardown and doesn't call into a
// subtest mock whose Once() expectations may already be exhausted.
// Registered last at the parent level so it runs first (t.Cleanup is LIFO).
t.Cleanup(func() {
sErr := th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, childPolicy1.ID)
require.NoError(t, sErr)
th.App.Srv().ch.AccessControl = nil
})
childPolicy2 := &model.AccessControlPolicy{
Type: model.AccessControlPolicyTypeChannel,
ID: ch2.Id,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_2,
// saveChildPolicy provisions a fresh child policy for the given channel,
// linked to parentPolicy, and registers a t.Cleanup that removes the row
// at the end of the calling subtest. Save is idempotent (it moves any
// existing row to history and inserts a new revision), so repeated calls
// across subtests are safe even when a previous subtest deleted the row.
saveChildPolicy := func(t *testing.T, channelID string) *model.AccessControlPolicy {
t.Helper()
child := &model.AccessControlPolicy{
Type: model.AccessControlPolicyTypeChannel,
ID: channelID,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_2,
}
require.Nil(t, child.Inherit(parentPolicy))
saved, sErr := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, child)
require.NoError(t, sErr)
require.NotNil(t, saved)
t.Cleanup(func() {
// Idempotent: store Delete is a no-op when no row exists, which
// is exactly the case when the subtest's UnassignPoliciesFromChannels
// successfully removed it.
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, saved.ID)
})
return saved
}
appErrInherit2 := childPolicy2.Inherit(parentPolicy)
require.Nil(t, appErrInherit2)
childPolicy2, err = th.App.Srv().Store().AccessControlPolicy().Save(th.Context, childPolicy2)
require.NoError(t, err)
require.NotNil(t, childPolicy2)
t.Cleanup(func() {
sErr := th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, childPolicy2.ID)
require.NoError(t, sErr)
})
// bindStoreDelete wires the mock's DeletePolicy to delegate to the real
// store. This way successful mock invocations actually drop the underlying
// row and the subtest can verify deletion at the store level — not just
// at the mock-assertion level.
bindStoreDelete := func(m *mocks.AccessControlServiceInterface) {
m.On("DeletePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string")).
Return(func(rctx request.CTX, id string) *model.AppError {
if err := th.App.Srv().Store().AccessControlPolicy().Delete(rctx, id); err != nil {
return model.NewAppError("DeletePolicy", "test.delete", nil, err.Error(), http.StatusInternalServerError)
}
return nil
}).Maybe()
}
t.Run("Feature not enabled", func(t *testing.T) {
childPolicy1 := saveChildPolicy(t, ch1.Id)
childPolicy2 := saveChildPolicy(t, ch2.Id)
th.App.Srv().ch.AccessControl = nil
appErr := th.App.UnassignPoliciesFromChannels(th.Context, parentPolicy.ID, []string{ch1.Id, ch2.Id})
require.NotNil(t, appErr)
assert.Equal(t, "app.pap.unassign_access_control_policy_from_channels.app_error", appErr.Id)
// No mock available — skip mock assertions. Always verify store state:
// the function bailed before touching anything, so both rows must remain.
_, sErr := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy1.ID)
require.NoError(t, sErr, "child policy for ch1 should remain in store when feature is disabled")
_, sErr = th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy2.ID)
require.NoError(t, sErr, "child policy for ch2 should remain in store when feature is disabled")
})
t.Run("Error deleting policy from AccessControlService", func(t *testing.T) {
childPolicy1 := saveChildPolicy(t, ch1.Id)
childPolicy2 := saveChildPolicy(t, ch2.Id)
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
t.Cleanup(func() { th.App.Srv().ch.AccessControl = nil })
mockAccessControl.On("SearchPolicies", th.Context, model.AccessControlPolicySearch{
Type: model.AccessControlPolicyTypeChannel,
ParentID: parentPolicy.ID,
Limit: 1000,
}).Return([]*model.AccessControlPolicy{childPolicy1}, mock.Anything, nil).Once()
mockAccessControl.On("GetPolicy", th.Context, ch1.Id).Return(childPolicy1, nil).Once()
expectedErr := model.NewAppError("DeletePolicy", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, "failed to delete from acs", http.StatusInternalServerError)
@ -749,37 +996,81 @@ func TestUnassignPoliciesFromChannels(t *testing.T) {
assert.Equal(t, expectedErr.Id, appErr.Id)
assert.Equal(t, expectedErr.Message, appErr.Message)
// Mock assertions: service IS available so we can assert which methods
// were dispatched. The function bails on the first DeletePolicy error,
// so ch2 must NOT have been processed.
mockAccessControl.AssertCalled(t, "DeletePolicy", th.Context, ch1.Id)
mockAccessControl.AssertNotCalled(t, "DeletePolicy", th.Context, ch2.Id)
// Always verify store state regardless of the mock outcome: the
// mock returned an error so the row for ch1 must still exist, and
// ch2 was never reached.
_, sErr := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy1.ID)
require.NoError(t, sErr, "child policy for ch1 should remain when DeletePolicy fails")
_, sErr = th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy2.ID)
require.NoError(t, sErr, "child policy for ch2 should remain when iteration short-circuits")
})
t.Run("Channel not actually a child policy", func(t *testing.T) {
childPolicy1 := saveChildPolicy(t, ch1.Id)
childPolicy2 := saveChildPolicy(t, ch2.Id)
ch3 := th.CreatePrivateChannel(t, th.BasicTeam) // Not a child of parentPolicy
t.Cleanup(func() { _ = th.App.PermanentDeleteChannel(th.Context, ch3) })
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
// Clear the mock before ch3 cleanup runs (LIFO: registered after the
// channel cleanup so it runs first), so cleanupChannelAccessControlPolicy
// during teardown takes the store fallback path.
t.Cleanup(func() { th.App.Srv().ch.AccessControl = nil })
mockAccessControl.On("GetPolicy", th.Context, ch1.Id).Return(childPolicy1, nil).Once()
mockAccessControl.On("GetPolicy", th.Context, ch2.Id).Return(childPolicy2, nil).Once()
mockAccessControl.On("DeletePolicy", th.Context, ch1.Id).Return(nil).Once()
mockAccessControl.On("DeletePolicy", th.Context, ch2.Id).Return(nil).Once()
bindStoreDelete(mockAccessControl)
appErr := th.App.UnassignPoliciesFromChannels(th.Context, parentPolicy.ID, []string{ch1.Id, ch2.Id, ch3.Id})
require.Nil(t, appErr)
// Mock assertions: ch1 and ch2 are parent's children → DeletePolicy invoked;
// ch3 is not → must be skipped without ever calling DeletePolicy.
mockAccessControl.AssertCalled(t, "DeletePolicy", th.Context, ch1.Id)
mockAccessControl.AssertCalled(t, "DeletePolicy", th.Context, ch2.Id)
mockAccessControl.AssertNotCalled(t, "DeletePolicy", th.Context, ch3.Id)
// Always verify store state — the mocked DeletePolicy delegates to the
// real store, so the rows for ch1 and ch2 must be gone.
_, sErr := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy1.ID)
require.Error(t, sErr, "child policy for ch1 should be removed from store")
_, sErr = th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy2.ID)
require.Error(t, sErr, "child policy for ch2 should be removed from store")
})
t.Run("Successful unassignment", func(t *testing.T) {
childPolicy1 := saveChildPolicy(t, ch1.Id)
childPolicy2 := saveChildPolicy(t, ch2.Id)
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
t.Cleanup(func() { th.App.Srv().ch.AccessControl = nil })
mockAccessControl.On("DeletePolicy", th.Context, ch1.Id).Return(nil).Once()
mockAccessControl.On("DeletePolicy", th.Context, ch2.Id).Return(nil).Once()
mockAccessControl.On("GetPolicy", th.Context, ch1.Id).Return(childPolicy1, nil).Once()
mockAccessControl.On("GetPolicy", th.Context, ch2.Id).Return(childPolicy2, nil).Once()
bindStoreDelete(mockAccessControl)
appErr := th.App.UnassignPoliciesFromChannels(th.Context, parentPolicy.ID, []string{ch1.Id, ch2.Id})
require.Nil(t, appErr)
// Mock assertions: service available, both targets must have been
// dispatched through DeletePolicy.
mockAccessControl.AssertCalled(t, "DeletePolicy", th.Context, ch1.Id)
mockAccessControl.AssertCalled(t, "DeletePolicy", th.Context, ch2.Id)
// Always verify store-level deletion regardless of mock state.
_, sErr := th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy1.ID)
require.Error(t, sErr, "child policy for ch1 should be removed from store")
_, sErr = th.App.Srv().Store().AccessControlPolicy().Get(th.Context, childPolicy2.ID)
require.Error(t, sErr, "child policy for ch2 should be removed from store")
})
t.Run("Invalidate channel cache", func(t *testing.T) {
@ -883,7 +1174,7 @@ func TestValidateChannelAccessControlPermission(t *testing.T) {
assert.Equal(t, "app.channel.get.existing.app_error", appErr.Id)
})
t.Run("Public channel should fail", func(t *testing.T) {
t.Run("Public channel should succeed", func(t *testing.T) {
th.AddUserToChannel(t, channelAdmin, publicChannel)
// Make user channel admin for public channel
@ -891,8 +1182,7 @@ func TestValidateChannelAccessControlPermission(t *testing.T) {
require.Nil(t, appErr2)
appErr2 = th.App.ValidateChannelAccessControlPermission(th.Context, channelAdmin.Id, publicChannel.Id)
require.NotNil(t, appErr2)
assert.Equal(t, "app.pap.access_control.channel_not_private", appErr2.Id)
require.Nil(t, appErr2)
})
t.Run("Shared channel should fail", func(t *testing.T) {
@ -917,6 +1207,20 @@ func TestValidateChannelAccessControlPermission(t *testing.T) {
require.NotNil(t, appErr3)
assert.Equal(t, "app.pap.access_control.channel_shared", appErr3.Id)
})
t.Run("Default channel should fail", func(t *testing.T) {
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
require.Nil(t, appErr)
th.AddUserToChannel(t, channelAdmin, townSquare)
_, appErr = th.App.UpdateChannelMemberRoles(th.Context, townSquare.Id, channelAdmin.Id, "channel_user channel_admin")
require.Nil(t, appErr)
appErr = th.App.ValidateChannelAccessControlPermission(th.Context, channelAdmin.Id, townSquare.Id)
require.NotNil(t, appErr)
assert.Equal(t, "app.pap.access_control.channel_default", appErr.Id)
})
}
func TestValidateAccessControlPolicyPermission(t *testing.T) {
@ -978,6 +1282,11 @@ func TestValidateAccessControlPolicyPermission(t *testing.T) {
// Set up mock Access Control service
mockAccessControl := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockAccessControl
// Clear the mock before per-channel cleanups run (LIFO: registered after
// channel/policy cleanups so it runs first), so PermanentDeleteChannel's
// cleanupChannelAccessControlPolicy is a no-op during teardown.
t.Cleanup(func() { th.App.Srv().ch.AccessControl = nil })
mockAccessControl.On("GetPolicy", th.Context, channelPolicy.ID).Return(channelPolicy, nil)
mockAccessControl.On("GetPolicy", th.Context, parentPolicy.ID).Return(parentPolicy, nil)
mockAccessControl.On("GetPolicy", th.Context, mock.AnythingOfType("string")).Return(nil, model.NewAppError("GetPolicy", "app.access_control_policy.get.app_error", nil, "not found", http.StatusNotFound))
@ -1092,7 +1401,7 @@ func TestValidateChannelAccessControlPolicyCreation(t *testing.T) {
assert.Equal(t, "app.access_control.insufficient_permissions", appErr.Id)
})
t.Run("Creating policy for public channel should fail", func(t *testing.T) {
t.Run("Creating policy for public channel should succeed", func(t *testing.T) {
publicChannel := th.CreateChannel(t, th.BasicTeam)
t.Cleanup(func() {
appErr := th.App.PermanentDeleteChannel(th.Context, publicChannel)
@ -1116,8 +1425,7 @@ func TestValidateChannelAccessControlPolicyCreation(t *testing.T) {
}
appErr4 = th.App.ValidateChannelAccessControlPolicyCreation(th.Context, channelAdmin.Id, policy)
require.NotNil(t, appErr4)
assert.Equal(t, "app.pap.access_control.channel_not_private", appErr4.Id)
require.Nil(t, appErr4)
})
t.Run("Creating policy for shared channel should fail", func(t *testing.T) {
@ -1152,6 +1460,30 @@ func TestValidateChannelAccessControlPolicyCreation(t *testing.T) {
require.NotNil(t, appErr5)
assert.Equal(t, "app.pap.access_control.channel_shared", appErr5.Id)
})
t.Run("Creating policy for default channel should fail", func(t *testing.T) {
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
require.Nil(t, appErr)
th.AddUserToChannel(t, channelAdmin, townSquare)
_, appErr = th.App.UpdateChannelMemberRoles(th.Context, townSquare.Id, channelAdmin.Id, "channel_user channel_admin")
require.Nil(t, appErr)
policy := &model.AccessControlPolicy{
ID: townSquare.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{Actions: []string{"membership"}, Expression: "true"},
},
}
appErr = th.App.ValidateChannelAccessControlPolicyCreation(th.Context, channelAdmin.Id, policy)
require.NotNil(t, appErr)
assert.Equal(t, "app.pap.access_control.channel_default", appErr.Id)
})
}
func TestTestExpressionWithChannelContext(t *testing.T) {
@ -1636,3 +1968,117 @@ func TestHasPermissionToFileAction(t *testing.T) {
assert.True(t, result)
})
}
func TestGetRecommendedPublicChannelsForUser(t *testing.T) {
th := Setup(t).InitBasic(t)
originalACS := th.App.Srv().ch.AccessControl
t.Cleanup(func() { th.App.Srv().ch.AccessControl = originalACS })
t.Run("returns empty when license is missing", func(t *testing.T) {
// No enterprise license set on the test server: the license short-circuit
// at the top of the function must keep the response empty without ever
// calling the access control service.
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
channels, appErr := th.App.GetRecommendedPublicChannelsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, appErr)
assert.Empty(t, channels)
mockACS.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
})
t.Run("returns empty when access control service is nil", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
th.App.Srv().ch.AccessControl = nil
channels, appErr := th.App.GetRecommendedPublicChannelsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, appErr)
assert.Empty(t, channels)
})
t.Run("returns only channels the policy allows; tolerates per-channel eval errors", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
mockACS := &mocks.AccessControlServiceInterface{}
th.App.Srv().ch.AccessControl = mockACS
// PermanentDeleteChannel calls cleanupChannelAccessControlPolicy → DeletePolicy
// during the test cleanup phase. Allow it as a no-op so cleanups don't fail
// the test on unexpected mock calls.
mockACS.On("DeletePolicy", mock.Anything, mock.AnythingOfType("string")).
Return((*model.AppError)(nil)).Maybe()
// Three policy-enforced public channels covering allow / deny / eval-error,
// plus one bare public channel without a policy. The bare channel must
// never reach the AccessEvaluation loop because SearchAllChannels filters
// it out via AccessControlPolicyEnforced=true.
allow := th.CreateChannel(t, th.BasicTeam)
deny := th.CreateChannel(t, th.BasicTeam)
evalErr := th.CreateChannel(t, th.BasicTeam)
bare := th.CreateChannel(t, th.BasicTeam)
t.Cleanup(func() {
for _, ch := range []*model.Channel{allow, deny, evalErr, bare} {
_ = th.App.PermanentDeleteChannel(th.Context, ch)
}
})
policyEnforced := func(channelID string) {
policy := &model.AccessControlPolicy{
ID: channelID,
Type: model.AccessControlPolicyTypeChannel,
Revision: 1,
Version: model.AccessControlPolicyVersionV0_2,
Active: true,
Rules: []model.AccessControlPolicyRule{
{Actions: []string{"membership"}, Expression: "true"},
},
}
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
require.NoError(t, err)
t.Cleanup(func() {
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, channelID)
})
}
policyEnforced(allow.Id)
policyEnforced(deny.Id)
policyEnforced(evalErr.Id)
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == allow.Id && req.Action == "membership"
})).Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil))
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == deny.Id
})).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
// Per-channel evaluation errors must NOT abort the whole request — the
// channel is dropped from the recommendation list and the loop moves on.
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
return req.Resource.ID == evalErr.Id
})).Return(model.AccessDecision{}, model.NewAppError("AccessEvaluation", "test.eval.error", nil, "boom", http.StatusInternalServerError))
channels, appErr := th.App.GetRecommendedPublicChannelsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, appErr)
ids := make([]string, 0, len(channels))
for _, ch := range channels {
ids = append(ids, ch.Id)
}
assert.ElementsMatch(t, []string{allow.Id}, ids,
"only the channel whose policy allows the subject should be returned (deny/eval-error excluded)")
assert.NotContains(t, ids, bare.Id, "channel without a policy should never enter the candidate set")
mockACS.AssertExpectations(t)
})
}

View file

@ -729,12 +729,30 @@ func (a *App) GetGroupChannel(rctx request.CTX, userIDs []string) (*model.Channe
// UpdateChannel updates a given channel by its Id. It also publishes the CHANNEL_UPDATED event.
func (a *App) UpdateChannel(rctx request.CTX, channel *model.Channel) (*model.Channel, *model.AppError) {
ok, appErr := a.ChannelAccessControlled(rctx, channel.Id)
enforced, appErr := a.ChannelAccessControlled(rctx, channel.Id)
if appErr != nil {
return nil, appErr
}
if ok && channel.Type != model.ChannelTypePrivate {
return nil, model.NewAppError("UpdateChannel", "api.channel.update_channel.not_allowed.app_error", nil, "", http.StatusForbidden)
if enforced {
if channel.Type != model.ChannelTypePrivate && channel.Type != model.ChannelTypeOpen {
return nil, model.NewAppError("UpdateChannel", "api.channel.update_channel.not_allowed.app_error", nil, "", http.StatusForbidden)
}
// Block public ↔ private conversion while an ABAC policy is attached.
// Public-channel and private-channel ABAC have asymmetric semantics
// (advisory recommend/auto-add vs hard-gate with member removal); a
// silent type flip would change what the existing policy actually
// does to members. The admin must remove the policy first and
// re-apply it after the conversion if they still want it.
current, getErr := a.Srv().Store().Channel().Get(channel.Id, true)
if getErr != nil {
return nil, model.NewAppError("UpdateChannel", "app.channel.get.find.app_error", nil, "", http.StatusInternalServerError).Wrap(getErr)
}
if current.Type != channel.Type {
return nil, model.NewAppError("UpdateChannel",
"api.channel.update_channel.policy_enforced_type_conversion.app_error",
nil, "channel has an active ABAC policy; remove the policy before converting between public and private", http.StatusBadRequest)
}
}
_, err := a.Srv().Store().Channel().Update(rctx, channel)
@ -1693,6 +1711,12 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str
return model.NewAppError("DeleteChannel", "app.post_persistent_notification.delete_by_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Archiving a channel tears down the membership policy attached to it.
// If the channel is later restored the admin can re-apply policies;
// leaving the policy row behind would otherwise keep the sync job
// processing an archived channel.
a.cleanupChannelAccessControlPolicy(rctx, channel)
a.Srv().Platform().InvalidateCacheForChannel(channel)
var message *model.WebSocketEvent
@ -3412,6 +3436,12 @@ func (a *App) PermanentDeleteChannel(rctx request.CTX, channel *model.Channel) *
return model.NewAppError("PermanentDeleteChannel", "app.channel.permanent_delete.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
// Remove any orphaned channel-scope ABAC policy tied to this channel ID.
// cleanupChannelAccessControlPolicy intentionally does not gate on
// PolicyEnforced (see its doc comment) — the underlying DeletePolicy is a
// no-op when no row exists, so it's safe to call unconditionally.
a.cleanupChannelAccessControlPolicy(rctx, channel)
a.Srv().Platform().InvalidateCacheForChannel(channel)
var message *model.WebSocketEvent
@ -4143,6 +4173,167 @@ func (a *App) ChannelAccessControlled(rctx request.CTX, channelID string) (bool,
return channel.PolicyEnforced, nil
}
// cleanupChannelAccessControlPolicy removes the channel-scope ABAC policy row,
// if any, for a channel being archived or permanently deleted. Orphan policy
// rows left behind would still be picked up by the sync job and surface in
// searches that filter by AccessControlPolicyEnforced. Failures are logged
// but not returned — deleting/archiving a channel must not be blocked by an
// ABAC cleanup error.
//
// We intentionally do NOT gate this on channel.PolicyEnforced: that flag is
// computed from the AccessControlPolicies table via the channel store and can
// be stale when read through the channel cache, which would cause us to skip
// cleanup and leave an orphaned policy behind. DeletePolicy is a no-op when
// no matching row exists, so calling it unconditionally is safe.
//
// When the enterprise access control service is unavailable (acs == nil) or
// reports the operation as unsupported (NotImplemented / NotAcceptable —
// e.g. running on Team Edition or under a license that gates the ABAC
// engine), we still need to remove the underlying row to avoid leaving an
// orphaned policy behind. In those cases we fall back to deleting directly
// through the access control policy store.
func (a *App) cleanupChannelAccessControlPolicy(rctx request.CTX, channel *model.Channel) {
if channel == nil || channel.Id == "" {
return
}
useStoreFallback := false
acs := a.Srv().Channels().AccessControl
if acs == nil {
useStoreFallback = true
} else if appErr := acs.DeletePolicy(rctx, channel.Id); appErr != nil {
switch appErr.StatusCode {
case http.StatusNotImplemented, http.StatusNotAcceptable:
useStoreFallback = true
default:
rctx.Logger().Warn("Failed to delete channel ABAC policy during channel delete/archive",
mlog.String("channel_id", channel.Id),
mlog.Err(appErr),
)
}
}
if useStoreFallback {
if err := a.Srv().Store().AccessControlPolicy().Delete(rctx, channel.Id); err != nil {
rctx.Logger().Warn("Failed to delete channel ABAC policy during channel delete/archive",
mlog.String("channel_id", channel.Id),
mlog.Err(err),
)
}
}
}
// recommendedPublicChannelsScanPageSize is the per-page size used while
// paginating through policy-enforced public channels in
// GetRecommendedPublicChannelsForUser.
const recommendedPublicChannelsScanPageSize = 200
// recommendedPublicChannelsScanCap is a hard upper bound on the total number
// of candidate channels we will evaluate per request. Evaluation is O(n) and
// CEL programs are non-trivial; this guards against runaway scans on
// pathological deployments. In practice teams have far fewer policy-enforced
// public channels than this cap.
const recommendedPublicChannelsScanCap = 2000
// GetRecommendedPublicChannelsForUser returns public channels in the given team
// that have an ABAC policy assigned and whose membership rule the user
// satisfies. ABAC policies on public channels are advisory — this list is
// consumed by the "Recommended channels" feature in the Browse Channels UI.
//
// Channels the user is already a member of are intentionally NOT filtered out:
// membership filtering is a presentation concern handled by the caller (the
// Browse Channels UI has its own "Hide joined channels" preference and may
// want to show membership state alongside each recommendation).
//
// Returns an empty list when the Enterprise Advanced license or ABAC feature
// flag is not available, or when the access control service is not wired up.
func (a *App) GetRecommendedPublicChannelsForUser(rctx request.CTX, userID, teamID string) (model.ChannelList, *model.AppError) {
if l := a.License(); !model.MinimumEnterpriseAdvancedLicense(l) || !*a.Config().AccessControlSettings.EnableAttributeBasedAccessControl {
return model.ChannelList{}, nil
}
acs := a.Srv().Channels().AccessControl
if acs == nil {
return model.ChannelList{}, nil
}
user, appErr := a.GetUser(userID)
if appErr != nil {
return nil, appErr
}
subject, appErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles)
if appErr != nil {
return nil, appErr
}
// Page through policy-enforced public channels until the team is fully
// scanned or we hit the hard cap. SearchAllChannels signals "no more
// pages" by returning fewer rows than the requested page size.
candidates := make(model.ChannelListWithTeamData, 0)
for page := 0; len(candidates) < recommendedPublicChannelsScanCap; page++ {
batch, _, searchErr := a.SearchAllChannels(rctx, "", model.ChannelSearchOpts{
TeamIds: []string{teamID},
Public: true,
AccessControlPolicyEnforced: true,
Page: model.NewPointer(page),
PerPage: model.NewPointer(recommendedPublicChannelsScanPageSize),
})
if searchErr != nil {
return nil, searchErr
}
if len(batch) == 0 {
break
}
// Truncate to the remaining cap so the final candidate count never
// exceeds the documented bound. The loop condition only gates entry
// to a new iteration; without this, a final page worth of channels
// could push len past the cap by up to (pageSize - 1).
remaining := recommendedPublicChannelsScanCap - len(candidates)
if len(batch) > remaining {
candidates = append(candidates, batch[:remaining]...)
break
}
candidates = append(candidates, batch...)
if len(batch) < recommendedPublicChannelsScanPageSize {
break
}
}
// We intentionally do NOT filter out channels the user is already a member
// of here — the Browse Channels UI has its own "Hide joined channels"
// preference and callers may want to show membership state next to each
// recommendation. Membership filtering is a presentation concern.
recommended := make(model.ChannelList, 0, len(candidates))
for _, channel := range candidates {
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
Subject: *subject,
Resource: model.Resource{
Type: model.AccessControlPolicyTypeChannel,
ID: channel.Id,
},
Action: "membership",
})
if evalErr != nil {
rctx.Logger().Debug("ABAC evaluation failed when computing recommended channels",
mlog.String("user_id", userID),
mlog.String("channel_id", channel.Id),
mlog.Err(evalErr),
)
continue
}
if !decision.Decision {
continue
}
recommended = append(recommended, &channel.Channel)
}
return recommended, nil
}
func (a *App) handleChannelCategoryName(channel *model.Channel) {
if *a.Config().ExperimentalSettings.ExperimentalChannelCategorySorting && strings.Contains(channel.DisplayName, "/") {
parts := strings.Split(channel.DisplayName, "/")

View file

@ -69,10 +69,57 @@ func (a *App) SearchTeamAccessPolicies(rctx request.CTX, teamID, requesterID str
return policies[i].ID < policies[j].ID
})
// Filter by self-inclusion.
// Single batched Channel lookup for all policies' child_ids so we don't
// issue GetChannelsByIds once per policy (N+1).
unionSet := make(map[string]struct{})
for _, policy := range policies {
ids, ok := childIDsFromPolicyProps(policy)
if !ok {
continue
}
for _, id := range ids {
unionSet[id] = struct{}{}
}
}
union := make([]string, 0, len(unionSet))
for id := range unionSet {
union = append(union, id)
}
sort.Strings(union)
var idToType map[string]model.ChannelType
batchLookupFailed := false
if len(union) > 0 {
channels, err := a.Srv().Store().Channel().GetChannelsByIds(union, true)
if err != nil {
rctx.Logger().Warn("Failed to look up child channels for self-inclusion gating batch; keeping the filter on",
mlog.Err(err),
mlog.Int("policy_count", len(policies)),
)
batchLookupFailed = true
} else {
if len(channels) != len(union) {
rctx.Logger().Warn("Partial batch child-channel lookup for self-inclusion gating",
mlog.Int("requested", len(union)),
mlog.Int("returned", len(channels)),
)
}
idToType = make(map[string]model.ChannelType, len(channels))
for _, ch := range channels {
idToType[ch.Id] = ch.Type
}
}
} else {
idToType = map[string]model.ChannelType{}
}
// Filter by self-inclusion. Skip the filter for parent policies whose
// children are all public channels — public-channel ABAC is advisory and
// can never lock the requesting admin out, so a non-matching expression
// is not a reason to hide the policy from them.
filtered := make([]*model.AccessControlPolicy, 0, len(policies))
for _, policy := range policies {
if len(policy.Rules) > 0 {
if len(policy.Rules) > 0 && policyAppliesToPrivateChannel(rctx, policy, idToType, batchLookupFailed) {
expression := policy.Rules[0].Expression
matches, matchErr := a.ValidateExpressionAgainstRequester(rctx, expression, requesterID)
if matchErr != nil {
@ -241,9 +288,10 @@ func (a *App) ReconcilePolicyTeamScope(rctx request.CTX, policyID string) *model
// - At least one channel provided
// - All channels exist
// - All channels belong to the given team
// - All channels are private
// - All channels are public or private (DM/GM excluded)
// - No group-constrained channels
// - No shared channels
// - No team default channels (e.g. town-square)
func (a *App) ValidateTeamScopePolicyChannelAssignment(rctx request.CTX, teamID string, channelIDs []string) *model.AppError {
if len(channelIDs) == 0 {
return model.NewAppError("ValidateTeamScopePolicyChannelAssignment",
@ -270,7 +318,7 @@ func (a *App) ValidateTeamScopePolicyChannelAssignment(rctx request.CTX, teamID
"channel does not belong to this team", http.StatusBadRequest)
}
if appErr := ValidateChannelEligibilityForAccessControl(channel); appErr != nil {
if appErr := a.ValidateChannelEligibilityForAccessControl(rctx, channel); appErr != nil {
return appErr
}
}
@ -300,3 +348,82 @@ func (a *App) ValidateTeamAdminSelfInclusion(rctx request.CTX, userID, expressio
return nil
}
// childIDsFromPolicyProps reads Props["child_ids"], which is normally []string
// (set by the policy store) but may be []any after JSON round-trip.
// If ok is false, the shape is unsupported and callers should treat it as unknown.
func childIDsFromPolicyProps(policy *model.AccessControlPolicy) (ids []string, ok bool) {
if policy == nil || policy.Props == nil {
return nil, false
}
switch raw := policy.Props["child_ids"].(type) {
case []string:
return raw, true
case []any:
out := make([]string, 0, len(raw))
for _, item := range raw {
if id, itemOk := item.(string); itemOk {
out = append(out, id)
}
}
return out, true
default:
return nil, false
}
}
// policyAppliesToPrivateChannel reports whether a parent policy has at least
// one private child channel — used to gate the self-inclusion filter, which
// only matters when a non-matching admin could actually be locked out. Public
// channels under ABAC are advisory (no member removal, anyone can join), so
// a non-matching admin is never at risk there.
//
// idToType maps channel ID → channel type from a batched GetChannelsByIds;
// batchLookupFailed means the batch store call failed and the conservative
// answer is to apply the filter.
//
// Returns `true` (i.e. apply the filter) on metadata or lookup errors, since
// the safer fallback is to keep the existing behavior rather than silently
// expose a policy a private channel might depend on.
func policyAppliesToPrivateChannel(rctx request.CTX, policy *model.AccessControlPolicy, idToType map[string]model.ChannelType, batchLookupFailed bool) bool {
if policy == nil || policy.Props == nil {
return true
}
childIDs, propsOk := childIDsFromPolicyProps(policy)
if !propsOk {
return true
}
if len(childIDs) == 0 {
// Channel-less policy (newly created, not yet assigned). Until a
// channel is attached there's nothing to lock anyone out of, so the
// filter is meaningless here.
return false
}
if batchLookupFailed {
return true
}
found := 0
for _, id := range childIDs {
if _, ok := idToType[id]; ok {
found++
}
}
if found != len(childIDs) {
rctx.Logger().Warn("Partial child-channel lookup for self-inclusion gating; keeping the filter on",
mlog.String("policy_id", policy.ID),
mlog.Int("requested", len(childIDs)),
mlog.Int("returned", found),
)
return true
}
for _, id := range childIDs {
if idToType[id] == model.ChannelTypePrivate {
return true
}
}
return false
}

View file

@ -83,10 +83,9 @@ func TestValidateTeamScopePolicyChannelAssignment(t *testing.T) {
require.NotNil(t, appErr)
})
t.Run("public channel returns error", func(t *testing.T) {
t.Run("public channel is eligible", func(t *testing.T) {
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{th.BasicChannel.Id})
require.NotNil(t, appErr)
assert.Equal(t, "app.pap.access_control.channel_not_private", appErr.Id)
require.Nil(t, appErr)
})
t.Run("channel from wrong team returns error", func(t *testing.T) {
@ -120,6 +119,15 @@ func TestValidateTeamScopePolicyChannelAssignment(t *testing.T) {
assert.Equal(t, "app.pap.access_control.channel_group_constrained", appErr.Id)
})
t.Run("default channel returns error", func(t *testing.T) {
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
require.Nil(t, appErr)
appErr = th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{townSquare.Id})
require.NotNil(t, appErr)
assert.Equal(t, "app.pap.access_control.channel_default", appErr.Id)
})
t.Run("valid private channel in team succeeds", func(t *testing.T) {
channel := th.CreatePrivateChannel(t, th.BasicTeam)
@ -138,9 +146,12 @@ func TestValidateTeamScopePolicyChannelAssignment(t *testing.T) {
t.Run("mix of valid and invalid channels returns error", func(t *testing.T) {
validChannel := th.CreatePrivateChannel(t, th.BasicTeam)
appErr := th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{
townSquare, appErr := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
require.Nil(t, appErr)
appErr = th.App.ValidateTeamScopePolicyChannelAssignment(th.Context, th.BasicTeam.Id, []string{
validChannel.Id,
th.BasicChannel.Id, // public — invalid
townSquare.Id, // default channel — invalid
})
require.NotNil(t, appErr)
})

View file

@ -615,6 +615,10 @@
"id": "api.channel.update_channel.not_allowed.app_error",
"translation": "Policy enforced channels cannot be updated."
},
{
"id": "api.channel.update_channel.policy_enforced_type_conversion.app_error",
"translation": "This channel has an attribute-based membership policy applied. Remove the policy before converting between public and private."
},
{
"id": "api.channel.update_channel.tried.app_error",
"translation": "Tried to perform an invalid update of the default channel {{.Channel}}."
@ -7298,18 +7302,22 @@
"id": "app.oauth.update_app.updating.app_error",
"translation": "We encountered an error updating the app."
},
{
"id": "app.pap.access_control.channel_default",
"translation": "Membership policies cannot be applied to team default channels."
},
{
"id": "app.pap.access_control.channel_group_constrained",
"translation": "Channel is group constrained and cannot have access control policies applied."
},
{
"id": "app.pap.access_control.channel_not_private",
"translation": "Access control policies can only be applied to private channels."
},
{
"id": "app.pap.access_control.channel_shared",
"translation": "Shared channels cannot have access control policy applied."
},
{
"id": "app.pap.access_control.channel_type_not_supported",
"translation": "Access control policies can only be applied to public or private channels."
},
{
"id": "app.pap.access_control.insufficient_channel_permissions",
"translation": "You do not have permission to manage access control for this channel."

View file

@ -12,10 +12,10 @@ exports[`components/admin_console/access_control/PolicyList should match snapsho
class="policy-header-text"
>
<h1>
Access Control Policies
Membership Policies
</h1>
<p>
Create policies containing attribute based access rules and the resources they apply to.
Create policies containing attribute-based membership rules and the channels they apply to.
</p>
</div>
<button
@ -117,10 +117,10 @@ exports[`components/admin_console/access_control/PolicyList should match snapsho
class="policy-header-text"
>
<h1>
Access Control Policies
Membership Policies
</h1>
<p>
Create policies containing attribute based access rules and the resources they apply to.
Create policies containing attribute-based membership rules and the channels they apply to.
</p>
</div>
<button
@ -331,10 +331,10 @@ exports[`components/admin_console/access_control/PolicyList should match snapsho
class="policy-header-text"
>
<h1>
Access Control Policies
Membership Policies
</h1>
<p>
Create policies containing attribute based access rules and the resources they apply to.
Create policies containing attribute-based membership rules and the channels they apply to.
</p>
</div>
<button

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {JobType, JobTypeBase, Job} from '@mattermost/types/jobs';
@ -23,6 +24,7 @@ type Props = {
};
export default function AccessControlSyncJobTable(props: Props): JSX.Element {
const {formatMessage} = useIntl();
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [showModal, setShowModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -81,8 +83,18 @@ export default function AccessControlSyncJobTable(props: Props): JSX.Element {
<div className='AccessControlSyncJobTable'>
<div className='policy-header'>
<div className='policy-header-text'>
<h1>{'Access Control Sync Jobs'}</h1>
<p>{'Synchronize access control policies with system resources and permissions.'}</p>
<h1>
<FormattedMessage
id='admin.access_control.sync_jobs.title'
defaultMessage='Membership Sync Jobs'
/>
</h1>
<p>
<FormattedMessage
id='admin.access_control.sync_jobs.description'
defaultMessage='Apply membership policies to their assigned resources.'
/>
</p>
</div>
<button
className='btn btn-primary'
@ -90,7 +102,19 @@ export default function AccessControlSyncJobTable(props: Props): JSX.Element {
disabled={isSubmitting}
>
<i className='icon icon-plus'/>
<span>{isSubmitting ? 'Running Job...' : 'Run Sync Job'}</span>
<span>
{isSubmitting ? (
<FormattedMessage
id='admin.access_control.sync_jobs.running'
defaultMessage='Running Job...'
/>
) : (
<FormattedMessage
id='admin.access_control.sync_jobs.run'
defaultMessage='Run Sync Job'
/>
)}
</span>
</button>
</div>
<JobsTable
@ -98,7 +122,10 @@ export default function AccessControlSyncJobTable(props: Props): JSX.Element {
jobType={JobTypes.ACCESS_CONTROL_SYNC}
hideJobCreateButton={true}
className={'job-table__access-control'}
createJobButtonText={'Create Job'}
createJobButtonText={formatMessage({
id: 'admin.access_control.sync_jobs.create_job',
defaultMessage: 'Create Job',
})}
disabled={false}
createJobHelpText={<></>}
onRowClick={handleRowClick}

View file

@ -13,12 +13,44 @@ type Props = {
onExited: () => void;
onConfirm: (apply: boolean) => void;
channelsAffected: number;
publicChannelsAffected?: number;
privateChannelsAffected?: number;
}
export default function PolicyConfirmationModal({active, onExited, onConfirm, channelsAffected}: Props) {
export default function PolicyConfirmationModal({active, onExited, onConfirm, channelsAffected, publicChannelsAffected = 0, privateChannelsAffected = 0}: Props) {
const {formatMessage} = useIntl();
const [enforceImmediately, setEnforceImmediately] = useState(true);
const hasMix = publicChannelsAffected > 0 && privateChannelsAffected > 0;
const hasOnlyPublic = publicChannelsAffected > 0 && privateChannelsAffected === 0;
let bodyText: string;
if (hasMix) {
bodyText = active ? formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.mixed',
defaultMessage: 'This policy is applied to channels of mixed types. For private channels, matching users will be granted access and non-matching members will be removed. For public channels, matching users will see these channels as recommendations and will be auto-added when auto-add is enabled; no existing members will be removed.',
}) : formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.mixed_inactive',
defaultMessage: 'This policy is applied to channels of mixed types. For private channels, only matching users can be added and non-matching existing members will be removed. For public channels, the policy acts as a recommendation only; no existing members will be removed.',
});
} else if (hasOnlyPublic) {
bodyText = active ? formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.public',
defaultMessage: 'Matching users will see these public channels as recommendations and, when auto-add is enabled, will be added automatically. Anyone can still join these channels; no existing members will be removed.',
}) : formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.public_inactive',
defaultMessage: 'Matching users will see these public channels as recommendations only; no existing members will be removed. Turn on Active (auto-add) to add matching users automatically.',
});
} else {
bodyText = active ? formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body',
defaultMessage: 'Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this membership policy.',
}) : formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.inactive',
defaultMessage: 'Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this membership policy.',
});
}
return (
<GenericModal
className={'PolicyConfirmationModal'}
@ -29,7 +61,7 @@ export default function PolicyConfirmationModal({active, onExited, onConfirm, ch
modalHeaderText={
<FormattedMessage
id='admin.access_control.policy.save_policy_confirmation_title'
defaultMessage='Save access control policy '
defaultMessage='Save membership policy'
/>
}
modalSubheaderText={
@ -63,17 +95,7 @@ export default function PolicyConfirmationModal({active, onExited, onConfirm, ch
>
<div className='body'>
{active ? (
formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body',
defaultMessage: 'Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.',
})
) : (
formatMessage({
id: 'admin.access_control.policy.save_policy_confirmation_body.inactive',
defaultMessage: 'Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.',
})
)}
{bodyText}
</div>
<div className='enforce-toggle'>
@ -94,11 +116,11 @@ export default function PolicyConfirmationModal({active, onExited, onConfirm, ch
{enforceImmediately ?
formatMessage({
id: 'admin.access_control.policy.channels_affected',
defaultMessage: 'Are you sure you want to save and apply the access control policy?',
defaultMessage: 'Are you sure you want to save and apply the membership policy?',
}) :
formatMessage({
id: 'admin.access_control.policy.save_only',
defaultMessage: 'Are you sure you want to save this access control policy?',
defaultMessage: 'Are you sure you want to save this membership policy?',
})
}
</div>

View file

@ -92,6 +92,7 @@
.error-status-content {
display: flex;
min-width: 0; // allow the code block to respect the modal width
height: 100%;
flex-direction: column;
padding: 0px 32px 32px;
@ -99,6 +100,23 @@
&__title {
color: var(--error-text);
}
// Sync-job errors often contain long identifiers and paths inside a
// single JSON string. Without wrapping, one line can stretch the
// modal far beyond the viewport. Scope the wrap behavior to this
// specific code block so we don't alter the global post code styles
// used in message posts.
.post-code {
max-width: 100%;
overflow-x: auto;
code,
.hljs {
overflow-wrap: anywhere;
white-space: pre-wrap;
word-break: break-word;
}
}
}
.canceled-status-content {

View file

@ -33,13 +33,13 @@ export default function PolicySelectionModal(props: Props): JSX.Element {
modalHeaderText={(
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy_title'
defaultMessage='Select an Access Control Policy'
defaultMessage='Select a Membership Policy'
/>
)}
modalSubheaderText={(
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy_description'
defaultMessage='An access control policy will restrict channel membership based on user attributes.'
defaultMessage='A membership policy defines who should be in this channel based on user attributes.'
/>
)}
>

View file

@ -353,13 +353,13 @@ export default function PolicyList(props: Props): JSX.Element {
<h1>
<FormattedMessage
id='admin.access_control.policies.title'
defaultMessage='Access Control Policies'
defaultMessage='Membership Policies'
/>
</h1>
<p>
<FormattedMessage
id='admin.access_control.policies.description'
defaultMessage='Create policies containing attribute based access rules and the resources they apply to.'
defaultMessage='Create policies containing attribute-based membership rules and the channels they apply to.'
/>
</p>
</div>

View file

@ -13,7 +13,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
class="fa fa-angle-left back"
href="/admin_console/system_attributes/membership_policies"
/>
Edit Access Control Policy
Edit Membership Policy
</div>
</div>
<div
@ -34,7 +34,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
data-testid="admin.access_control.policy.edit_policy.policyNamelabel"
for="admin.access_control.policy.edit_policy.policyName"
>
Access control policy name:
Membership policy name:
</label>
<div
class="col-sm-8"
@ -65,12 +65,12 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
<div
class="text-top"
>
Attribute-based access rules
Attribute-based membership rules
</div>
<div
class="text-bottom"
>
Select user attributes and values as rules to restrict channel membership.
Select user attributes and values as rules to determine who should be in the channels this policy applies to.
</div>
</div>
<button
@ -197,7 +197,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
<div
class="text-bottom"
>
Add channels that this attribute-based access policy will apply to.
Add channels that this membership policy will apply to.
</div>
</div>
<button
@ -508,7 +508,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
class="fa fa-angle-left back"
href="/admin_console/system_attributes/membership_policies"
/>
Edit Access Control Policy
Edit Membership Policy
</div>
</div>
<div
@ -529,7 +529,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
data-testid="admin.access_control.policy.edit_policy.policyNamelabel"
for="admin.access_control.policy.edit_policy.policyName"
>
Access control policy name:
Membership policy name:
</label>
<div
class="col-sm-8"
@ -560,12 +560,12 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
<div
class="text-top"
>
Attribute-based access rules
Attribute-based membership rules
</div>
<div
class="text-bottom"
>
Select user attributes and values as rules to restrict channel membership.
Select user attributes and values as rules to determine who should be in the channels this policy applies to.
</div>
</div>
<button
@ -692,7 +692,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
<div
class="text-bottom"
>
Add channels that this attribute-based access policy will apply to.
Add channels that this membership policy will apply to.
</div>
</div>
<button

View file

@ -26,6 +26,7 @@ import TextSetting from 'components/widgets/settings/text_setting';
import {useChannelAccessControlActions} from 'hooks/useChannelAccessControlActions';
import {getHistory} from 'utils/browser_history';
import Constants from 'utils/constants';
import ChannelList from './channel_list';
@ -36,8 +37,6 @@ import PolicyConfirmationModal from '../modals/confirmation/confirmation_modal';
import './policy_details.scss';
const DEFAULT_PAGE_SIZE = 10;
interface PolicyActions {
fetchPolicy: (id: string) => Promise<ActionResult>;
createPolicy: (policy: AccessControlPolicy) => Promise<ActionResult>;
@ -90,6 +89,10 @@ function PolicyDetails({
const [saveNeeded, setSaveNeeded] = useState(false);
const [saving, setSaving] = useState(false);
const [channelsCount, setChannelsCount] = useState(0);
// Map of saved channelId → channel type. Lets the confirmation modal show
// the right messaging for mixed / public-only / private-only policies.
const [savedChannelTypes, setSavedChannelTypes] = useState<Record<string, string>>({});
const [autocompleteResult, setAutocompleteResult] = useState<UserPropertyField[]>([]);
const [attributesLoaded, setAttributesLoaded] = useState(false);
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
@ -99,15 +102,15 @@ function PolicyDetails({
const abacActions = useChannelAccessControlActions();
// Memoize the custom no options message to avoid recreating it on every render
const customNoPrivateChannelsMessage = useMemo(() => (
const customNoChannelsMessage = useMemo(() => (
<div
key='no-private-channels'
key='no-channels-available'
className='no-channel-message'
>
<p className='primary-message'>
<FormattedMessage
id='admin.access_control.policy.edit_policy.no_private_channels'
defaultMessage='There are no private channels available to add to this policy.'
id='admin.access_control.policy.edit_policy.no_channels_available'
defaultMessage='There are no channels available to add to this policy.'
/>
</p>
</div>
@ -140,8 +143,13 @@ function PolicyDetails({
setAutoSyncMembership(result.data?.active || false);
});
const channelsPromise = actions.searchChannels(policyId, '', {per_page: DEFAULT_PAGE_SIZE}).then((result) => {
// Fetch the full assigned-channel list (not just a page) to know
// the public/private split for the confirmation modal. The policy
// assignment permission limits this to 1000; match that ceiling.
const channelsPromise = actions.searchChannels(policyId, '', {per_page: 1000}).then((result) => {
const channels: ChannelWithTeamData[] = result.data?.channels || [];
setChannelsCount(result.data?.total_count || 0);
setSavedChannelTypes(Object.fromEntries(channels.map((ch) => [ch.id, ch.type])));
});
// Wait for all fetches for an existing policy
@ -364,7 +372,7 @@ function PolicyDetails({
/>
<FormattedMessage
id='admin.access_control.policy.edit_policy.title'
defaultMessage='Edit Access Control Policy'
defaultMessage='Edit Membership Policy'
/>
</div>
</AdminHeader>
@ -376,7 +384,7 @@ function PolicyDetails({
label={
<FormattedMessage
id='admin.access_control.policy.edit_policy.policyName'
defaultMessage='Access control policy name:'
defaultMessage='Membership policy name:'
/>
}
value={policyName}
@ -427,13 +435,13 @@ function PolicyDetails({
title={
<FormattedMessage
id='admin.access_control.policy.edit_policy.access_rules.title'
defaultMessage='Attribute-based access rules'
defaultMessage='Attribute-based membership rules'
/>
}
subtitle={
<FormattedMessage
id='admin.access_control.policy.edit_policy.access_rules.subtitle'
defaultMessage='Select user attributes and values as rules to restrict channel membership.'
defaultMessage='Select user attributes and values as rules to determine who should be in the channels this policy applies to.'
/>
}
buttonText={
@ -530,7 +538,7 @@ function PolicyDetails({
subtitle={
<FormattedMessage
id='admin.access_control.policy.edit_policy.channel_selector.subtitle'
defaultMessage='Add channels that this attribute-based access policy will apply to.'
defaultMessage='Add channels that this membership policy will apply to.'
/>
}
buttonText={
@ -606,20 +614,47 @@ function PolicyDetails({
onChannelsSelected={(channels) => addToNewChannels(channels)}
groupID={''}
alreadySelected={Object.values(channelChanges.added).map((channel) => channel.id)}
excludeTypes={['O', 'D', 'G']}
customNoOptionsMessage={customNoPrivateChannelsMessage}
excludeTypes={['D', 'G']}
customNoOptionsMessage={customNoChannelsMessage}
excludeGroupConstrained={true}
excludeDefaultChannels={true}
/>
)}
{showConfirmationModal && (
<PolicyConfirmationModal
active={autoSyncMembership}
onExited={() => setShowConfirmationModal(false)}
onConfirm={handleSubmit}
channelsAffected={(channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length}
/>
)}
{showConfirmationModal && (() => {
// Effective channel mix = (saved - removed) + added. The
// confirmation modal messages the user differently for mixed,
// private-only, and public-only selections.
let publicCount = 0;
let privateCount = 0;
for (const [id, type] of Object.entries(savedChannelTypes)) {
if (channelChanges.removed[id]) {
continue;
}
if (type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
for (const ch of Object.values(channelChanges.added)) {
if (ch.type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (ch.type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
return (
<PolicyConfirmationModal
active={autoSyncMembership}
onExited={() => setShowConfirmationModal(false)}
onConfirm={handleSubmit}
channelsAffected={(channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length}
publicChannelsAffected={publicCount}
privateChannelsAffected={privateCount}
/>
);
})()}
{showDeleteConfirmationModal && (
<GenericModal

View file

@ -278,9 +278,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
>
<button
aria-pressed="false"
class="btn btn-lg btn-toggle disabled"
class="btn btn-lg btn-toggle"
data-testid="policy-enforce-toggle-button"
disabled=""
id="policy-enforce-toggle"
tabindex="0"
type="button"
@ -300,7 +299,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
<div
class="help-text-small help-text-no-padding "
>
Only private channels can be attribute based.
Recommend this channel to users whose attributes match the rules and (optionally) auto-add them. Anyone can still join freely.
</div>
</div>
</div>
@ -720,9 +719,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
>
<button
aria-pressed="false"
class="btn btn-lg btn-toggle disabled"
class="btn btn-lg btn-toggle"
data-testid="policy-enforce-toggle-button"
disabled=""
id="policy-enforce-toggle"
tabindex="0"
type="button"
@ -742,7 +740,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
<div
class="help-text-small help-text-no-padding "
>
Only private channels can be attribute based.
Recommend this channel to users whose attributes match the rules and (optionally) auto-add them. Anyone can still join freely.
</div>
</div>
</div>
@ -1107,9 +1105,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
>
<button
aria-pressed="false"
class="btn btn-lg btn-toggle disabled"
class="btn btn-lg btn-toggle"
data-testid="policy-enforce-toggle-button"
disabled=""
id="policy-enforce-toggle"
tabindex="0"
type="button"
@ -1129,7 +1126,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
<div
class="help-text-small help-text-no-padding "
>
Only private channels can be attribute based.
Recommend this channel to users whose attributes match the rules and (optionally) auto-add them. Anyone can still join freely.
</div>
</div>
</div>
@ -1392,9 +1389,8 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
>
<button
aria-pressed="false"
class="btn btn-lg btn-toggle disabled"
class="btn btn-lg btn-toggle"
data-testid="policy-enforce-toggle-button"
disabled=""
id="policy-enforce-toggle"
tabindex="0"
type="button"
@ -1414,7 +1410,7 @@ exports[`admin_console/team_channel_settings/channel/ChannelDetails should match
<div
class="help-text-small help-text-no-padding "
>
Only private channels can be attribute based.
Recommend this channel to users whose attributes match the rules and (optionally) auto-add them. Anyone can still join freely.
</div>
</div>
</div>

View file

@ -113,8 +113,8 @@ export const ChannelAccessControl: React.FC<Props> = (props: Props): JSX.Element
return (
<AdminPanelWithButton
id='channel_access_control_policy'
title={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_title', defaultMessage: 'Access policy'})}
subtitle={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_description', defaultMessage: 'Select an access policy for this channel to restrict membership.'})}
title={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_title', defaultMessage: 'Membership policy'})}
subtitle={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_description', defaultMessage: 'Select a membership policy for this channel.'})}
buttonText={defineMessage({id: 'admin.channel_settings.channel_detail.link_policy', defaultMessage: 'Link to a policy'})}
onButtonClick={() => {
handleOpenPolicyModal();
@ -135,8 +135,8 @@ export const ChannelAccessControl: React.FC<Props> = (props: Props): JSX.Element
return (
<AdminPanelWithButton
id='channel_access_control_with_policy'
title={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_title', defaultMessage: 'Access policy'})}
subtitle={defineMessage({id: 'admin.channel_settings.channel_detail.policy_following', defaultMessage: 'This channel is currently using the following access policy.'})}
title={defineMessage({id: 'admin.channel_settings.channel_detail.access_control_policy_title', defaultMessage: 'Membership policy'})}
subtitle={defineMessage({id: 'admin.channel_settings.channel_detail.policy_following', defaultMessage: 'This channel is currently using the following membership policy.'})}
buttonText={defineMessage({id: 'admin.channel_settings.channel_detail.remove_policy', defaultMessage: 'Remove all'})}
onButtonClick={() => {
actions.onPolicyRemoveAll();
@ -145,14 +145,6 @@ export const ChannelAccessControl: React.FC<Props> = (props: Props): JSX.Element
<div className='group-teams-and-channels'>
<div className='group-teams-and-channels--body'>
<div className='access-policy-container'>
{!accessControlPolicies && (
<div className='access-policy-description'>
<FormattedMessage
id='admin.channel_settings.channel_detail.select_policy'
defaultMessage='Apply an access policy for this channel to restrict membership'
/>
</div>
)}
{renderTable()}
</div>
</div>

View file

@ -118,10 +118,40 @@ const PolicyEnforceToggle = (props: Props): JSX.Element | null => {
if (isSynced) {
return null;
}
let subTitle: JSX.Element;
if (isDefault) {
subTitle = (
<FormattedMessage
id='admin.channel_settings.channel_details.default_channel_not_supported'
defaultMessage='The default channel cannot have a membership policy.'
/>
);
} else if (isPublic) {
subTitle = (
<FormattedMessage
id='admin.channel_settings.channel_details.attribute_based_description_public'
defaultMessage='Recommend this channel to users whose attributes match the rules and (optionally) auto-add them. Anyone can still join freely.'
/>
);
} else {
subTitle = (
<FormattedMessage
id='admin.channel_settings.channel_details.attribute_based_description'
defaultMessage='Restrict which users can be invited to this channel based on their user attributes and values. Only people who match the specified conditions will be allowed to be selected and added to this channel.'
/>
);
}
return (
<LineSwitch
id='policy-enforce-toggle'
disabled={isDisabled || isSynced || isPublic || !policyEnforcedToggleAvailable}
// Keep the visual disabled state aligned with the click guard in
// onToggle (which short-circuits when isDefault is true). Without
// including isDefault here the toggle would look enabled but do
// nothing on the default channel.
disabled={isDisabled || isSynced || isDefault || !policyEnforcedToggleAvailable}
toggled={policyEnforced}
last={true}
onToggle={() => {
@ -136,18 +166,7 @@ const PolicyEnforceToggle = (props: Props): JSX.Element | null => {
defaultMessage='Enable attribute based channel access'
/>
)}
subTitle={isDefault || isPublic ? (
<FormattedMessage
id='admin.channel_settings.channel_details.private_channel_only'
defaultMessage='Only private channels can be attribute based.'
/>
) : (
<FormattedMessage
id='admin.channel_settings.channel_details.attribute_based_description'
defaultMessage='Restrict which users can be invited to this channel based on their user attributes and values. Only people who match the specified conditions will be allowed to be selected and added to this channel.'
/>
)
}
subTitle={subTitle}
/>
);
};

View file

@ -140,6 +140,7 @@ describe('components/BrowseChannels', () => {
teamName: 'team_name',
channelsRequestStarted: false,
shouldHideJoinedChannels: false,
accessControlEnabled: false,
myChannelMemberships: {
'channel-id-3': TestHelper.getChannelMembershipMock({
channel_id: 'channel-id-3',
@ -149,6 +150,7 @@ describe('components/BrowseChannels', () => {
actions: {
getChannels: jest.fn(channelActions.getChannels),
getArchivedChannels: jest.fn(channelActions.getArchivedChannels),
getRecommendedChannelsForUser: jest.fn().mockResolvedValue({data: []}),
joinChannel: jest.fn(channelActions.joinChannelAction),
searchAllChannels: jest.fn(channelActions.searchAllChannels),
openModal: jest.fn(),
@ -645,4 +647,66 @@ describe('components/BrowseChannels', () => {
expect(screen.queryByText('Private Not Member')).not.toBeInTheDocument();
});
});
test('Recommended filter fetches recommended channels, boosts them on All, and lists only recommended when filtered', async () => {
const recommendedChannel = TestHelper.getChannelMock({
id: 'recommended-channel-id',
team_id: 'team_1',
display_name: 'Recommended Channel',
name: 'recommended-channel',
type: 'O',
});
const getChannels = jest.fn().mockResolvedValue({
data: [defaultChannel, recommendedChannel],
});
const getRecommendedChannelsForUser = jest.fn().mockResolvedValue({data: [recommendedChannel]});
const props = {
...baseProps,
accessControlEnabled: true,
channels: [defaultChannel, recommendedChannel],
actions: {...baseProps.actions, getChannels, getRecommendedChannelsForUser},
};
renderWithContext(<BrowseChannels {...props}/>);
await waitFor(() => {
expect(getRecommendedChannelsForUser).toHaveBeenCalledWith('team_1');
});
await waitFor(() => {
expect(screen.getByTestId('ChannelRow-recommended-channel')).toBeInTheDocument();
expect(screen.getByTestId('ChannelRow-default-channel')).toBeInTheDocument();
});
const recommendedRow = screen.getByTestId('ChannelRow-recommended-channel');
const defaultRow = screen.getByTestId('ChannelRow-default-channel');
expect(
recommendedRow.compareDocumentPosition(defaultRow) & Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
await user.click(screen.getByLabelText('Channel type filter'));
await user.click(await screen.findByText('Recommended channels'));
await waitFor(() => {
expect(screen.getByTestId('ChannelRow-recommended-channel')).toBeInTheDocument();
expect(screen.queryByTestId('ChannelRow-default-channel')).not.toBeInTheDocument();
});
});
test('Recommended filter entry is hidden when ABAC is disabled', async () => {
renderWithContext(<BrowseChannels {...baseProps}/>);
await act(async () => {
await Promise.resolve();
});
await user.click(screen.getByLabelText('Channel type filter'));
expect(screen.queryByText('Recommended channels')).not.toBeInTheDocument();
// The recommendation fetch is also gated server-side on
// `accessControlEnabled`. Lock that in so a future refactor doesn't
// start fetching unconditionally and silently waste a round-trip.
expect(baseProps.actions.getRecommendedChannelsForUser).not.toHaveBeenCalled();
});
});

View file

@ -35,13 +35,28 @@ export enum Filter {
Public = 'Public',
Private = 'Private',
Archived = 'Archived',
Recommended = 'Recommended',
}
export type FilterType = keyof typeof Filter;
// Resolve the initial filter, defending against callers that ask for
// `Recommended` when ABAC isn't enabled — the dropdown would hide that menu
// item server-side, leaving the UI stuck on a filter the user can't toggle off.
function resolveInitialFilter(initialFilter: FilterType | undefined, accessControlEnabled: boolean): FilterType {
if (!initialFilter) {
return Filter.All;
}
if (initialFilter === Filter.Recommended && !accessControlEnabled) {
return Filter.All;
}
return initialFilter;
}
type Actions = {
getChannels: (teamId: string, page: number, perPage: number) => Promise<ActionResult<Channel[]>>;
getArchivedChannels: (teamId: string, page: number, channelsPerPage: number) => Promise<ActionResult<Channel[]>>;
getRecommendedChannelsForUser: (teamId: string) => Promise<ActionResult<Channel[]>>;
joinChannel: (currentUserId: string, teamId: string, channelId: string) => Promise<ActionResult>;
searchAllChannels: (term: string, opts?: ChannelSearchOpts) => Promise<ActionResult<Channel[] | ChannelsWithTotalCount>>;
openModal: <P>(modalData: ModalData<P>) => void;
@ -68,6 +83,8 @@ export type Props = {
rhsState?: RhsState;
rhsOpen?: boolean;
channelsMemberCount?: Record<string, number>;
accessControlEnabled: boolean;
initialFilter?: FilterType;
actions: Actions;
}
@ -79,6 +96,7 @@ type State = {
serverError: React.ReactNode | string;
searching: boolean;
searchTerm: string;
recommendedChannels: Channel[];
}
export default class BrowseChannels extends React.PureComponent<Props, State> {
@ -92,12 +110,13 @@ export default class BrowseChannels extends React.PureComponent<Props, State> {
this.state = {
loading: true,
filter: Filter.All,
filter: resolveInitialFilter(props.initialFilter, props.accessControlEnabled),
search: false,
searchedChannels: [],
serverError: null,
searching: false,
searchTerm: '',
recommendedChannels: [],
};
}
@ -107,22 +126,44 @@ export default class BrowseChannels extends React.PureComponent<Props, State> {
return;
}
const promises = [
const promises: Array<Promise<ActionResult<Channel[]>>> = [
this.props.actions.getChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2),
this.props.actions.getArchivedChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2),
];
if (this.props.accessControlEnabled) {
promises.push(this.props.actions.getRecommendedChannelsForUser(this.props.teamId).then((result) => {
if (result.data) {
this.setState({recommendedChannels: result.data});
}
return result;
}));
}
Promise.all(promises).then((results) => {
const channelIDsForMemberCount = results.flatMap((result) => {
return result.data ? result.data.map((channel) => channel.id) : [];
},
);
this.props.privateChannels.forEach((channel) => channelIDsForMemberCount.push(channel.id));
if (channelIDsForMemberCount.length > 0) {
this.props.actions.getChannelsMemberCount(channelIDsForMemberCount);
// Dedupe across the result lists + privateChannels: a recommended
// channel is also a public channel, so the same id can show up in
// both `getChannels` and `getRecommendedChannelsForUser` results.
// getChannelsMemberCount tolerates dupes but issuing them is
// wasted work and noisy.
const ids = new Set<string>();
for (const result of results) {
if (result.data) {
for (const channel of result.data) {
ids.add(channel.id);
}
}
}
for (const channel of this.props.privateChannels) {
ids.add(channel.id);
}
if (ids.size > 0) {
this.props.actions.getChannelsMemberCount(Array.from(ids));
}
this.loadComplete();
}).catch(() => {
this.loadComplete();
});
this.loadComplete();
}
loadComplete = () => {
@ -238,12 +279,37 @@ export default class BrowseChannels extends React.PureComponent<Props, State> {
if (this.state.filter === Filter.Archived) {
searchedChannels = channels.filter((c) => c.delete_at !== 0);
}
if (this.state.filter === Filter.Recommended) {
const recommendedIds = new Set(this.state.recommendedChannels.map((c) => c.id));
searchedChannels = channels.filter((c) => recommendedIds.has(c.id));
}
if (this.props.shouldHideJoinedChannels) {
searchedChannels = this.getChannelsWithoutJoined(searchedChannels);
}
searchedChannels = this.boostRecommendedChannels(searchedChannels);
this.setState({searchedChannels, searching: false});
};
// Boost recommended channels to the top of a list. Used as a light-touch
// prioritization signal so matching public channels surface first in the
// generic Browse Channels views.
boostRecommendedChannels = (channels: Channel[]): Channel[] => {
if (this.state.recommendedChannels.length === 0) {
return channels;
}
const recommendedIds = new Set(this.state.recommendedChannels.map((c) => c.id));
const recommended: Channel[] = [];
const rest: Channel[] = [];
for (const c of channels) {
if (recommendedIds.has(c.id)) {
recommended.push(c);
} else {
rest.push(c);
}
}
return [...recommended, ...rest];
};
changeFilter = (filter: FilterType) => {
// search again when switching channels to update search results
this.search(this.state.searchTerm);
@ -264,26 +330,32 @@ export default class BrowseChannels extends React.PureComponent<Props, State> {
getActiveChannels = () => {
const {channels, archivedChannels, shouldHideJoinedChannels, privateChannels} = this.props;
const {search, searchedChannels, filter} = this.state;
const {search, searchedChannels, filter, recommendedChannels} = this.state;
const allChannels = channels.concat(privateChannels).sort((a, b) => a.display_name.localeCompare(b.display_name));
const allChannelsWithoutJoined = this.getChannelsWithoutJoined(allChannels);
const publicChannelsWithoutJoined = this.getChannelsWithoutJoined(channels);
const archivedChannelsWithoutJoined = this.getChannelsWithoutJoined(archivedChannels);
const privateChannelsWithoutJoined = this.getChannelsWithoutJoined(privateChannels);
const recommendedChannelsWithoutJoined = this.getChannelsWithoutJoined(recommendedChannels);
const filterOptions = {
const filterOptions: Record<FilterType, Channel[]> = {
[Filter.All]: shouldHideJoinedChannels ? allChannelsWithoutJoined : allChannels,
[Filter.Archived]: shouldHideJoinedChannels ? archivedChannelsWithoutJoined : archivedChannels,
[Filter.Private]: shouldHideJoinedChannels ? privateChannelsWithoutJoined : privateChannels,
[Filter.Public]: shouldHideJoinedChannels ? publicChannelsWithoutJoined : channels,
[Filter.Recommended]: shouldHideJoinedChannels ? recommendedChannelsWithoutJoined : recommendedChannels,
};
if (search) {
return searchedChannels;
}
return filterOptions[filter] || filterOptions[Filter.All];
const activeList = filterOptions[filter] || filterOptions[Filter.All];
if (filter === Filter.Recommended) {
return activeList;
}
return this.boostRecommendedChannels(activeList);
};
render() {
@ -345,6 +417,7 @@ export default class BrowseChannels extends React.PureComponent<Props, State> {
handleJoin={this.handleJoin}
noResultsText={noResultsText}
loading={search ? searching : channelsRequestStarted}
showRecommendedFilter={this.props.accessControlEnabled}
changeFilter={this.changeFilter}
filter={this.state.filter}
myChannelMemberships={this.props.myChannelMemberships}

View file

@ -7,7 +7,7 @@ import type {Dispatch} from 'redux';
import type {Channel} from '@mattermost/types/channels';
import {getChannels, getArchivedChannels, joinChannel, getChannelsMemberCount, searchAllChannels} from 'mattermost-redux/actions/channels';
import {getChannels, getArchivedChannels, getRecommendedChannelsForUser, joinChannel, getChannelsMemberCount, searchAllChannels} from 'mattermost-redux/actions/channels';
import {RequestStatus} from 'mattermost-redux/constants';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getChannelsInCurrentTeam, getMyChannelMemberships, getChannelsMemberCount as getChannelsMemberCountSelector} from 'mattermost-redux/selectors/entities/channels';
@ -17,6 +17,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {setGlobalItem} from 'actions/storage';
import {openModal, closeModal} from 'actions/views/modals';
import {closeRightHandSide} from 'actions/views/rhs';
import {isChannelAccessControlEnabled} from 'selectors/general';
import {getIsRhsOpen, getRhsState} from 'selectors/rhs';
import {makeGetGlobalItem} from 'selectors/storage';
@ -61,6 +62,7 @@ function mapStateToProps(state: GlobalState) {
rhsState: getRhsState(state),
rhsOpen: getIsRhsOpen(state),
channelsMemberCount: getChannelsMemberCountSelector(state),
accessControlEnabled: isChannelAccessControlEnabled(state),
};
}
@ -69,6 +71,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
actions: bindActionCreators({
getChannels,
getArchivedChannels,
getRecommendedChannelsForUser,
joinChannel,
searchAllChannels,
openModal,

View file

@ -37,6 +37,10 @@
right: 20px;
}
&__recommended-tag {
margin-left: 8px;
}
&__policy-banner {
.TagGroup {
margin-top: 12px;

View file

@ -3,7 +3,7 @@
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import type {Channel, ChannelType} from '@mattermost/types/channels';
import type {TeamMembership} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {RelationOneToOne} from '@mattermost/types/utilities';
@ -56,6 +56,8 @@ jest.mock('utils/utils', () => {
jest.mock('mattermost-redux/client', () => ({
Client4: {
getProfilesNotInChannel: jest.fn(),
getProfilesMatchingChannelPolicy: jest.fn().mockResolvedValue([]),
searchUsers: jest.fn().mockResolvedValue([]),
getProfilePictureUrl: jest.fn(() => 'mock-url'),
getUsersRoute: jest.fn(() => '/api/v4/users'),
getTeamsRoute: jest.fn(() => '/api/v4/teams'),
@ -144,6 +146,8 @@ describe('components/channel_invite_modal', () => {
// Reset Client4 mocks before each test
const {Client4} = require('mattermost-redux/client');
Client4.getProfilesNotInChannel.mockClear();
Client4.getProfilesMatchingChannelPolicy.mockClear();
Client4.searchUsers.mockClear();
Client4.getProfilePictureUrl.mockClear();
Client4.getUsersRoute.mockClear();
Client4.getTeamsRoute.mockClear();
@ -153,6 +157,10 @@ describe('components/channel_invite_modal', () => {
// Set default return values
Client4.getProfilesNotInChannel.mockResolvedValue([]);
// Reset to default empty resolution so per-test overrides don't leak.
Client4.getProfilesMatchingChannelPolicy.mockResolvedValue([]);
Client4.searchUsers.mockResolvedValue([]);
Client4.getProfilePictureUrl.mockReturnValue('mock-url');
Client4.getUsersRoute.mockReturnValue('/api/v4/users');
Client4.getTeamsRoute.mockReturnValue('/api/v4/teams');
@ -527,69 +535,92 @@ describe('components/channel_invite_modal', () => {
});
test('should show loading state for access attributes', () => {
// Mock the useAccessControlAttributes hook to return loading state
// Mock the useAccessControlAttributes hook to return loading state.
// Use mockReturnValue (persistent) rather than mockReturnValueOnce so
// the mock survives the re-renders triggered by fetchRecommendedUserIds
// on public policy channels.
const useAccessControlAttributesModule = require('components/common/hooks/useAccessControlAttributes');
const useAccessControlAttributesMock = useAccessControlAttributesModule.default;
useAccessControlAttributesMock.mockReturnValueOnce({
const previousImpl = useAccessControlAttributesMock.getMockImplementation();
useAccessControlAttributesMock.mockReturnValue({
structuredAttributes: [],
loading: true,
error: null,
fetchAttributes: jest.fn(),
});
const channelWithPolicy = {
...channel,
policy_enforced: true,
};
try {
const channelWithPolicy = {
...channel,
policy_enforced: true,
};
const props = {
...baseProps,
channel: channelWithPolicy,
};
const props = {
...baseProps,
channel: channelWithPolicy,
};
renderWithContext(
<ChannelInviteModal {...props}/>,
);
renderWithContext(
<ChannelInviteModal {...props}/>,
);
// Modal renders in a portal, so query from document instead of container
expect(document.querySelector('.AlertBanner')).not.toBeNull();
// Modal renders in a portal, so query from document instead of container
expect(document.querySelector('.AlertBanner')).not.toBeNull();
// Check that no tags are shown (loading state)
expect(screen.queryByText('tag1')).not.toBeInTheDocument();
expect(screen.queryByText('tag2')).not.toBeInTheDocument();
// Check that no tags are shown (loading state)
expect(screen.queryByText('tag1')).not.toBeInTheDocument();
expect(screen.queryByText('tag2')).not.toBeInTheDocument();
} finally {
// try/finally so a mid-test assertion failure can't leak the
// mocked impl into the next test's render — the override
// intentionally returns loading=true, which would mask the real
// hook everywhere else in the suite.
if (previousImpl) {
useAccessControlAttributesMock.mockImplementation(previousImpl);
}
}
});
test('should handle error state for access attributes', () => {
// Mock the useAccessControlAttributes hook to return error state
const useAccessControlAttributesModule = require('components/common/hooks/useAccessControlAttributes');
const useAccessControlAttributesMock = useAccessControlAttributesModule.default;
useAccessControlAttributesMock.mockReturnValueOnce({
const previousImpl = useAccessControlAttributesMock.getMockImplementation();
useAccessControlAttributesMock.mockReturnValue({
structuredAttributes: [],
loading: false,
error: 'Failed to load attributes',
fetchAttributes: jest.fn(),
});
const channelWithPolicy = {
...channel,
policy_enforced: true,
};
try {
const channelWithPolicy = {
...channel,
policy_enforced: true,
};
const props = {
...baseProps,
channel: channelWithPolicy,
};
const props = {
...baseProps,
channel: channelWithPolicy,
};
renderWithContext(
<ChannelInviteModal {...props}/>,
);
renderWithContext(
<ChannelInviteModal {...props}/>,
);
// Modal renders in a portal, so query from document instead of container
expect(document.querySelector('.AlertBanner')).not.toBeNull();
// Modal renders in a portal, so query from document instead of container
expect(document.querySelector('.AlertBanner')).not.toBeNull();
// Check that no tags are shown (error state)
expect(screen.queryByText('tag1')).not.toBeInTheDocument();
expect(screen.queryByText('tag2')).not.toBeInTheDocument();
// Check that no tags are shown (error state)
expect(screen.queryByText('tag1')).not.toBeInTheDocument();
expect(screen.queryByText('tag2')).not.toBeInTheDocument();
} finally {
// try/finally for the same reason as the loading-state test
// above — a mid-test assertion failure must not leak the mocked
// hook into subsequent renders.
if (previousImpl) {
useAccessControlAttributesMock.mockImplementation(previousImpl);
}
}
});
// the multiselect returns several elements with the same text, usiing a custom function to get the correct one specifing the tagName
@ -598,12 +629,14 @@ describe('components/channel_invite_modal', () => {
element?.tagName === 'SPAN' && text.trim() === user,
) as HTMLElement;
test('should not include DM users when ABAC is enabled', async () => {
test('should not include DM users when ABAC is enabled on a private channel', async () => {
// Mock Client4 to return user-1 for ABAC channels
const {Client4} = require('mattermost-redux/client');
Client4.getProfilesNotInChannel.mockResolvedValue([users[0]]);
Client4.searchUsers.mockResolvedValue([users[0]]);
const channelWithPolicy = {...channel, policy_enforced: true};
// Private channels hard-gate the member list; DM users must be suppressed.
const channelWithPolicy = {...channel, type: 'P' as ChannelType, policy_enforced: true};
const props = {
...baseProps,
channel: channelWithPolicy,
@ -621,9 +654,8 @@ describe('components/channel_invite_modal', () => {
const input = screen.getByRole('combobox', {name: /search for people/i});
await userEvent.type(input, 'user');
// Wait for the search to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await waitFor(() => {
expect(Client4.searchUsers).toHaveBeenCalled();
});
// now only one visible <span> should match "user-1"
@ -693,9 +725,11 @@ describe('components/channel_invite_modal', () => {
expect(getProfilesNotInChannelMock).not.toHaveBeenCalled();
});
test('should hide the invite as guest link when channel has policy_enforced', () => {
test('should hide the invite as guest link on a private channel with policy_enforced', () => {
// Private + policy_enforced is a hard gate — guest invites are blocked.
const channelWithPolicy = {
...channel,
type: 'P' as ChannelType,
policy_enforced: true,
};
@ -714,6 +748,28 @@ describe('components/channel_invite_modal', () => {
expect(screen.queryByText('Invite as a Guest')).not.toBeInTheDocument();
});
test('should show the invite as guest link on a public channel with policy_enforced (advisory)', () => {
// Public + policy_enforced is advisory — guest invites remain available.
const channelWithPolicy = {
...channel,
type: 'O' as ChannelType,
policy_enforced: true,
};
const props = {
...baseProps,
channel: channelWithPolicy,
canInviteGuests: true,
emailInvitationsEnabled: true,
};
renderWithContext(
<ChannelInviteModal {...props}/>,
);
expect(screen.queryByText('Invite as a Guest')).toBeInTheDocument();
});
test('should NOT filter out groups when NOT ABAC is enforced', async () => {
const mockGroups = [
{
@ -757,10 +813,11 @@ describe('components/channel_invite_modal', () => {
expect(getUserSpan('Developers')).toBeInTheDocument();
});
test('should filter out groups when ABAC is enforced', async () => {
test('should filter out groups when ABAC is enforced on a private channel', async () => {
// Mock Client4 to return user-1 for ABAC channels
const {Client4} = require('mattermost-redux/client');
Client4.getProfilesNotInChannel.mockResolvedValue([users[0]]);
Client4.searchUsers.mockResolvedValue([users[0]]);
const mockGroups = [
{
@ -780,8 +837,10 @@ describe('components/channel_invite_modal', () => {
},
];
// Private + policy_enforced suppresses groups (hard gate).
const channelWithPolicy = {
...channel,
type: 'P' as ChannelType,
policy_enforced: true,
};
@ -802,9 +861,8 @@ describe('components/channel_invite_modal', () => {
const input = screen.getByRole('combobox', {name: /search for people/i});
await userEvent.type(input, 'user');
// Wait for the search to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await waitFor(() => {
expect(Client4.searchUsers).toHaveBeenCalled();
});
// Should only show users, not groups when ABAC is enforced
@ -814,14 +872,14 @@ describe('components/channel_invite_modal', () => {
expect(screen.queryByText('Developers')).toBeNull();
});
test('should force fresh API call when ABAC is enforced', async () => {
// For ABAC channels, we use Client4 directly, not the Redux action
test('should force fresh API call when ABAC is enforced on a private channel', async () => {
// For hard-gated (private) ABAC channels we bypass Redux and call Client4 directly.
const {Client4} = require('mattermost-redux/client');
Client4.getProfilesNotInChannel.mockResolvedValue([]);
const props = {
...baseProps,
channel: {...channel, policy_enforced: true},
channel: {...channel, type: 'P' as ChannelType, policy_enforced: true},
};
renderWithContext(<ChannelInviteModal {...props}/>);
@ -831,7 +889,6 @@ describe('components/channel_invite_modal', () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// For ABAC channels, we should call Client4 directly, not the Redux action
expect(Client4.getProfilesNotInChannel).toHaveBeenCalledWith(
props.channel.team_id,
props.channel.id,
@ -842,14 +899,16 @@ describe('components/channel_invite_modal', () => {
);
});
test('should ignore contaminated Redux data when ABAC is enforced', async () => {
// Mock Client4 to return only user-1 for ABAC channels (ignoring contaminated Redux data)
test('should ignore contaminated Redux data when ABAC is enforced on a private channel', async () => {
// Private + policy_enforced uses a dedicated fetch to get only matching users,
// ignoring Redux-backed sources that may include users from other parts of the app.
const {Client4} = require('mattermost-redux/client');
Client4.getProfilesNotInChannel.mockResolvedValue([users[0]]);
Client4.searchUsers.mockResolvedValue([users[0]]);
const props = {
...baseProps,
channel: {...channel, policy_enforced: true},
channel: {...channel, type: 'P' as ChannelType, policy_enforced: true},
profilesNotInCurrentChannel: [users[0]], // Clean ABAC data
profilesFromRecentDMs: [users[1]], // Contaminated data
includeUsers: {[users[1].id]: users[1]}, // Contaminated data
@ -857,7 +916,6 @@ describe('components/channel_invite_modal', () => {
renderWithContext(<ChannelInviteModal {...props}/>);
// Wait for the API call to complete and state to update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
@ -865,13 +923,64 @@ describe('components/channel_invite_modal', () => {
const input = screen.getByRole('combobox', {name: /search for people/i});
await userEvent.type(input, 'user');
// Wait for the search to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await waitFor(() => {
expect(Client4.searchUsers).toHaveBeenCalled();
});
// Should only show clean ABAC data
expect(getUserSpan('user-1')).toBeInTheDocument();
expect(screen.queryByText('user-2')).toBeNull();
});
test('public channel with policy_enforced marks matching users as Recommended and sorts them first', async () => {
// Advisory (public) policy: the invite list is the normal team list, but
// users returned by the recommended-users endpoint get a "Recommended"
// tag and are boosted to the top of the options.
const {Client4} = require('mattermost-redux/client');
Client4.getProfilesMatchingChannelPolicy.mockResolvedValue([users[1]]);
const props = {
...baseProps,
channel: {...channel, type: 'O' as ChannelType, policy_enforced: true},
profilesNotInCurrentChannel: [users[0], users[1]],
};
renderWithContext(<ChannelInviteModal {...props}/>);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const input = screen.getByRole('combobox', {name: /search for people/i});
await userEvent.type(input, 'user');
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Both users are still visible (advisory, not filtered).
const user1Span = getUserSpan('user-1');
const user2Span = getUserSpan('user-2');
expect(user1Span).toBeInTheDocument();
expect(user2Span).toBeInTheDocument();
// The matching user (user-2) gets the Recommended tag.
expect(screen.getByText('Recommended')).toBeInTheDocument();
// ...and is sorted to the top of the option list. compareDocumentPosition
// returns DOCUMENT_POSITION_FOLLOWING (4) when the second arg is later
// in the DOM than the first — so user-2 preceding user-1 yields 4.
// eslint-disable-next-line no-bitwise
expect(user2Span.compareDocumentPosition(user1Span) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The recommended-users endpoint was queried with an empty cursor
// for the first page; pagination terminates after a short batch.
expect(Client4.getProfilesMatchingChannelPolicy).toHaveBeenCalledWith(
props.channel.team_id,
props.channel.id,
props.channel.group_constrained,
50,
'',
);
});
});

View file

@ -103,8 +103,30 @@ const ChannelInviteModalComponent = (props: Props) => {
const [pageCursors, setPageCursors] = useState<{[page: number]: string}>({});
const [abacFilteredUsers, setAbacFilteredUsers] = useState<UserProfile[]>([]);
/** Server ABAC search hits when the user types a term on private policy-enforced channels (see Client4.searchUsers). Null means use {@link abacFilteredUsers} instead. */
const [privateAbacSearchHits, setPrivateAbacSearchHits] = useState<UserProfile[] | null>(null);
const [recommendedUserIds, setRecommendedUserIds] = useState<Set<string>>(new Set());
const searchTimeoutId = useRef<number>(0);
const selectedItemRef = useRef<HTMLDivElement>(null);
const privateAbacSearchSeq = useRef<number>(0);
// Monotonic token for fetchRecommendedUserIds — incremented every time we
// start a fresh fetch (or the channel changes). The pagination loop reads
// its captured token before each step and aborts/skips state writes when
// it sees a newer token, so a stale response from a prior channel can't
// overwrite recommendations for the current one.
const recommendedUserIdsRequestId = useRef<number>(0);
// Monotonic token for fetchAbacUsers — stale HTTP responses must not
// overwrite abacFilteredUsers after the user switches channels.
const abacProfilesFetchRequestId = useRef<number>(0);
// Public channels with a policy are advisory — the invite list is not
// filtered and matching users are merely surfaced as a recommendation.
// Private channels with a policy remain a hard gate.
const isPolicyEnforcedPrivate = props.channel.policy_enforced && props.channel.type !== Constants.OPEN_CHANNEL;
const isPolicyRecommendedPublic = props.channel.policy_enforced && props.channel.type === Constants.OPEN_CHANNEL;
// Use the useAccessControlAttributes hook
const {structuredAttributes} = useAccessControlAttributes(
@ -191,42 +213,58 @@ const ChannelInviteModalComponent = (props: Props) => {
const getOptions = useCallback(() => {
const excludedAndNotInTeamUserIds = excludedUsers;
// Only include DM users if ABAC is not enabled
// DM users and include_users are suppressed only for the hard-gated
// private policy path, since public policies are purely advisory.
let dmUsers: UserProfileValue[] = [];
if (!props.channel.policy_enforced) {
if (!isPolicyEnforcedPrivate) {
const filteredDmUsers = filterProfilesStartingWithTerm(props.profilesFromRecentDMs, term);
dmUsers = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredDmUsers, excludedAndNotInTeamUserIds).slice(0, USERS_FROM_DMS) as UserProfileValue[];
}
let users: UserProfileValue[];
if (props.channel.policy_enforced) {
// ABAC mode: Use local state with fresh API data, completely bypass Redux
const filteredUsers = filterProfilesStartingWithTerm(abacFilteredUsers, term);
users = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
if (isPolicyEnforcedPrivate) {
const sourceList =
term.trim().length > 0 ?
(privateAbacSearchHits ?? []) :
abacFilteredUsers;
users = filterOutDeletedAndExcludedAndNotInTeamUsers(sourceList, excludedAndNotInTeamUserIds);
} else {
// Non-ABAC mode: existing logic
// Non-ABAC or advisory (public policy): full team list.
const filteredUsers = filterProfilesStartingWithTerm(props.profilesNotInCurrentChannel.concat(props.profilesInCurrentChannel), term);
users = filterOutDeletedAndExcludedAndNotInTeamUsers(filteredUsers, excludedAndNotInTeamUserIds);
// Only include explicitly added users if ABAC is not enabled
if (props.includeUsers) {
users = [...users, ...Object.values(props.includeUsers)];
}
}
// Groups are suppressed only for the hard-gated private ABAC path.
const groupsAndUsers = [
// Only include groups if ABAC policy is NOT enforced
...(props.channel.policy_enforced ? [] : filterGroupsMatchingTerm(props.groups, term) as GroupValue[]),
...(isPolicyEnforcedPrivate ? [] : filterGroupsMatchingTerm(props.groups, term) as GroupValue[]),
...users,
].sort(sortUsersAndGroups);
const optionValues = [
let optionValues: Array<UserProfileValue | GroupValue> = [
...dmUsers,
...groupsAndUsers,
].slice(0, MAX_USERS);
];
return Array.from(new Set(optionValues));
// For advisory (public) policies, boost recommended users to the top
// while keeping the rest of the order stable.
if (isPolicyRecommendedPublic && recommendedUserIds.size > 0) {
const recommended: Array<UserProfileValue | GroupValue> = [];
const rest: Array<UserProfileValue | GroupValue> = [];
for (const opt of optionValues) {
if (isUser(opt) && recommendedUserIds.has(opt.id)) {
recommended.push(opt);
} else {
rest.push(opt);
}
}
optionValues = [...recommended, ...rest];
}
return Array.from(new Set(optionValues.slice(0, MAX_USERS)));
}, [
term,
props.profilesFromRecentDMs,
@ -234,10 +272,13 @@ const ChannelInviteModalComponent = (props: Props) => {
props.profilesInCurrentChannel,
props.includeUsers,
props.groups,
props.channel.policy_enforced,
isPolicyEnforcedPrivate,
isPolicyRecommendedPublic,
recommendedUserIds,
excludedUsers,
filterOutDeletedAndExcludedAndNotInTeamUsers,
abacFilteredUsers,
privateAbacSearchHits,
]);
// Handle modal hide
@ -267,8 +308,18 @@ const ChannelInviteModalComponent = (props: Props) => {
setLoadingUsers(loadingState);
}, []);
// Custom function to fetch ABAC users without polluting Redux store
// Custom function to fetch ABAC users without polluting Redux store.
// Used only for the private (hard-gated) policy path.
//
// The first call (page 0, no cursor) replaces the buffer; subsequent
// calls append (deduped by id) so paging through the policy-filtered
// list grows the in-memory buffer that getOptions() searches over.
// We can't merge with Redux profile lists here because the global
// search/profile actions don't apply the ABAC server-side filter, and
// doing so would surface non-matching users in the strict-gate UI.
const fetchAbacUsers = useCallback(async (page = 0, perPage = USERS_PER_PAGE, cursorId = '') => {
const requestId = ++abacProfilesFetchRequestId.current;
const isInitialLoad = page === 0 && !cursorId;
try {
const profiles = await Client4.getProfilesNotInChannel(
props.channel.team_id,
@ -278,14 +329,81 @@ const ChannelInviteModalComponent = (props: Props) => {
perPage,
cursorId,
);
setAbacFilteredUsers(profiles || []);
if (requestId !== abacProfilesFetchRequestId.current) {
return {data: profiles || []};
}
if (isInitialLoad) {
setAbacFilteredUsers(profiles || []);
} else if (profiles && profiles.length > 0) {
setAbacFilteredUsers((prev) => {
const seen = new Set(prev.map((u) => u.id));
const additions = profiles.filter((u) => !seen.has(u.id));
return additions.length > 0 ? [...prev, ...additions] : prev;
});
}
return {data: profiles || []};
} catch (error) {
setAbacFilteredUsers([]);
if (requestId !== abacProfilesFetchRequestId.current) {
return {error};
}
if (isInitialLoad) {
setAbacFilteredUsers([]);
}
return {error};
}
}, [props.channel.team_id, props.channel.id, props.channel.group_constrained]);
// For advisory (public) policies, fetch the matching-user subset to
// render a subtle "Recommended" indicator and boost them to the top.
// This bypasses Redux so the normal (unfiltered) list remains intact.
//
// Uses the cursor-based pagination on getProfilesMatchingChannelPolicy
// to walk every page of matching users; otherwise users beyond the
// first page would never be tagged as recommended. Capped to keep the
// tag-rendering set bounded for very large teams.
const fetchRecommendedUserIds = useCallback(async () => {
const RECOMMENDED_HARD_CAP = 1000;
const requestId = ++recommendedUserIdsRequestId.current;
try {
const ids = new Set<string>();
let cursorId = '';
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const profiles = await Client4.getProfilesMatchingChannelPolicy(
props.channel.team_id,
props.channel.id,
props.channel.group_constrained,
USERS_PER_PAGE,
cursorId,
);
// A newer fetch (or channel switch) bumped the token; bail
// before doing more work or writing stale results to state.
if (recommendedUserIdsRequestId.current !== requestId) {
return;
}
if (!profiles || profiles.length === 0) {
break;
}
for (const u of profiles) {
ids.add(u.id);
}
if (profiles.length < USERS_PER_PAGE || ids.size >= RECOMMENDED_HARD_CAP) {
break;
}
cursorId = profiles[profiles.length - 1].id;
}
if (recommendedUserIdsRequestId.current === requestId) {
setRecommendedUserIds(ids);
}
} catch {
if (recommendedUserIdsRequestId.current === requestId) {
setRecommendedUserIds(new Set());
}
}
}, [props.channel.team_id, props.channel.id, props.channel.group_constrained]);
// Handle page change with cursor-based pagination
const handlePageChange = useCallback((page: number, prevPage: number) => {
if (page > prevPage) {
@ -294,14 +412,23 @@ const ChannelInviteModalComponent = (props: Props) => {
// Get cursor for this page (if we're going forward)
const cursorId = page > 0 ? pageCursors[page - 1] : '';
props.actions.getProfilesNotInChannel(
props.channel.team_id,
props.channel.id,
props.channel.group_constrained,
page + 1,
USERS_PER_PAGE,
cursorId,
).then((result) => {
// Private ABAC channels page through Client4 directly so the
// result lands in our scoped abacFilteredUsers buffer that
// getOptions() reads from. Routing through Redux here would
// populate profilesNotInCurrentChannel which getOptions ignores
// on the strict-gate path, leaving subsequent pages invisible.
const fetchPage = isPolicyEnforcedPrivate ?
fetchAbacUsers(page + 1, USERS_PER_PAGE, cursorId) :
props.actions.getProfilesNotInChannel(
props.channel.team_id,
props.channel.id,
props.channel.group_constrained,
page + 1,
USERS_PER_PAGE,
cursorId,
);
fetchPage.then((result) => {
// Store the cursor for the next page (ID of the last user)
if (result.data && result.data.length > 0) {
const lastUserId = result.data[result.data.length - 1].id;
@ -315,9 +442,14 @@ const ChannelInviteModalComponent = (props: Props) => {
setUsersLoadingState(false);
});
props.actions.getProfilesInChannel(props.channel.id, page + 1, USERS_PER_PAGE, '', {active: true});
// Existing channel members are only relevant outside the strict
// ABAC path — its in-channel listing comes from policy data, not
// from the global Redux profile lists.
if (!isPolicyEnforcedPrivate) {
props.actions.getProfilesInChannel(props.channel.id, page + 1, USERS_PER_PAGE, '', {active: true});
}
}
}, [props.actions, props.channel, setUsersLoadingState, pageCursors, setPageCursors]);
}, [props.actions, props.channel, setUsersLoadingState, pageCursors, setPageCursors, isPolicyEnforcedPrivate, fetchAbacUsers]);
// Handle form submission
const handleSubmit = useCallback(() => {
@ -358,12 +490,39 @@ const ChannelInviteModalComponent = (props: Props) => {
if (!term) {
// Reset cursor state when clearing search
setPageCursors({});
setPrivateAbacSearchHits(null);
setUsersLoadingState(false);
return;
}
searchTimeoutId.current = window.setTimeout(
async () => {
if (isPolicyEnforcedPrivate) {
privateAbacSearchSeq.current++;
const seq = privateAbacSearchSeq.current;
setUsersLoadingState(true);
try {
const profiles = await Client4.searchUsers(term, {
team_id: props.channel.team_id,
not_in_channel_id: props.channel.id,
group_constrained: Boolean(props.channel.group_constrained),
limit: 100,
});
if (seq === privateAbacSearchSeq.current) {
setPrivateAbacSearchHits(profiles || []);
}
} catch {
if (seq === privateAbacSearchSeq.current) {
setPrivateAbacSearchHits([]);
}
} finally {
if (seq === privateAbacSearchSeq.current) {
setUsersLoadingState(false);
}
}
return;
}
const options = {
team_id: props.channel.team_id,
not_in_channel_id: props.channel.id,
@ -378,12 +537,12 @@ const ChannelInviteModalComponent = (props: Props) => {
include_member_count: true,
include_member_ids: true,
};
const promises = [
props.actions.searchProfiles(term, options),
];
// Only search for groups if groups are enabled AND ABAC policy is NOT enforced
if (props.isGroupsEnabled && !props.channel.policy_enforced) {
if (props.isGroupsEnabled) {
promises.push(props.actions.searchAssociatedGroupsForReference(term, props.channel.team_id, props.channel.id, opts));
}
await Promise.all(promises);
@ -391,7 +550,7 @@ const ChannelInviteModalComponent = (props: Props) => {
},
Constants.SEARCH_TIMEOUT_MILLISECONDS,
);
}, [props.actions, props.channel, props.isGroupsEnabled, setUsersLoadingState]);
}, [props.actions, props.channel, props.isGroupsEnabled, isPolicyEnforcedPrivate, setUsersLoadingState]);
// Render aria label for options
const renderAriaLabel = useCallback((option: UserProfileValue | GroupValue): string => {
@ -419,6 +578,7 @@ const ChannelInviteModalComponent = (props: Props) => {
userMapping[ProfilesInGroup[i]] = 'Already in channel';
}
const displayName = displayUsername(option, props.teammateNameDisplaySetting);
const isRecommended = isPolicyRecommendedPublic && recommendedUserIds.has(option.id);
return (
<div
key={option.id}
@ -443,6 +603,19 @@ const ChannelInviteModalComponent = (props: Props) => {
{'@'}{option.username}
</span>
}
{isRecommended && (
<AlertTag
className='channel-invite__recommended-tag'
text={props.intl.formatMessage({
id: 'channel_invite.recommended_tag',
defaultMessage: 'Recommended',
})}
tooltipTitle={props.intl.formatMessage({
id: 'channel_invite.recommended_tag.tooltip',
defaultMessage: 'Matches the suggested membership for this channel',
})}
/>
)}
<span
className='channel-invite__user-mapping light'
>
@ -476,17 +649,26 @@ const ChannelInviteModalComponent = (props: Props) => {
selectedItemRef={selectedItemRef}
/>
);
}, [props.profilesInCurrentChannel, props.teammateNameDisplaySetting, props.userStatuses]);
}, [props.profilesInCurrentChannel, props.teammateNameDisplaySetting, props.userStatuses, props.intl, isPolicyRecommendedPublic, recommendedUserIds]);
// Initial data loading - only run when channel changes or component mounts
useEffect(() => {
if (props.channel.policy_enforced) {
// For ABAC channels, use custom function to avoid Redux store pollution
privateAbacSearchSeq.current++;
setAbacFilteredUsers([]);
setRecommendedUserIds(new Set());
setPrivateAbacSearchHits(null);
setPageCursors({});
setUsersLoadingState(true);
if (isPolicyEnforcedPrivate) {
// Hard-gate ABAC: avoid Redux store pollution; only matching users.
fetchAbacUsers().then(() => {
setUsersLoadingState(false);
});
} else {
// For non-ABAC channels, use normal Redux actions
// Non-ABAC or advisory (public) policy: fetch the full team list
// via the standard Redux action. The server returns the unfiltered
// list for public policy-enforced channels.
props.actions.getProfilesNotInChannel(
props.channel.team_id,
props.channel.id,
@ -496,19 +678,36 @@ const ChannelInviteModalComponent = (props: Props) => {
).then(() => {
setUsersLoadingState(false);
});
if (isPolicyRecommendedPublic) {
fetchRecommendedUserIds();
}
}
props.actions.getProfilesInChannel(props.channel.id, 0, USERS_PER_PAGE, '', {active: true});
props.actions.getTeamStats(props.channel.team_id);
props.actions.loadStatusesForProfilesList(props.profilesNotInCurrentChannel);
props.actions.loadStatusesForProfilesList(props.profilesInCurrentChannel);
// Bump the request token on dep change / unmount so any in-flight
// fetchRecommendedUserIds (queued from a prior channel) sees a newer
// token and discards its results before they reach setState. Covers
// the cases the in-fetch token alone can't: switching from public to
// private (no new fetch is started, but the old one is still running)
// and modal unmount.
return () => {
recommendedUserIdsRequestId.current++;
abacProfilesFetchRequestId.current++;
};
}, [
props.channel.id,
props.channel.team_id,
props.channel.group_constrained,
props.channel.policy_enforced,
isPolicyEnforcedPrivate,
isPolicyRecommendedPublic,
props.actions,
fetchAbacUsers,
fetchRecommendedUserIds,
]);
// Compute options with useMemo to ensure they're always fresh
@ -521,8 +720,11 @@ const ChannelInviteModalComponent = (props: Props) => {
props.groups,
props.profilesNotInCurrentTeam,
props.excludeUsers,
props.channel.policy_enforced, // Add this to trigger recomputation when ABAC mode changes
abacFilteredUsers, // Add local ABAC state
isPolicyEnforcedPrivate,
isPolicyRecommendedPublic,
recommendedUserIds,
abacFilteredUsers,
privateAbacSearchHits,
]);
// Update team members when options change
@ -593,7 +795,7 @@ const ChannelInviteModalComponent = (props: Props) => {
InvitationModalLink: (chunks) => (
<InviteModalLink
id='customNoOptionsMessageLink'
abacChannelPolicyEnforced={props.channel.policy_enforced}
abacChannelPolicyEnforced={isPolicyEnforcedPrivate}
>
{chunks}
</InviteModalLink>
@ -670,13 +872,23 @@ const ChannelInviteModalComponent = (props: Props) => {
<AlertBanner
mode='info'
variant='app'
title={(
title={channel.type === Constants.OPEN_CHANNEL ? (
<FormattedMessage
id='channel_invite.policy_recommended.title'
defaultMessage='This channel has recommended members based on user attributes'
/>
) : (
<FormattedMessage
id='channel_invite.policy_enforced.title'
defaultMessage='Channel access is restricted by user attributes'
/>
)}
message={(
message={channel.type === Constants.OPEN_CHANNEL ? (
<FormattedMessage
id='channel_invite.policy_recommended.description'
defaultMessage='A membership policy suggests who should be members of this channel. You can still add anyone who can join a public channel.'
/>
) : (
<FormattedMessage
id='channel_invite.policy_enforced.description'
defaultMessage='Only people who match the specified access rules can be selected and added to this channel.'
@ -694,7 +906,7 @@ const ChannelInviteModalComponent = (props: Props) => {
teamId={channel.team_id}
users={usersNotInTeam}
/>
{(props.emailInvitationsEnabled && props.canInviteGuests && !channel.policy_enforced) && inviteGuestLink}
{(props.emailInvitationsEnabled && props.canInviteGuests && !isPolicyEnforcedPrivate) && inviteGuestLink}
</div>
</div>
</GenericModal>

View file

@ -209,11 +209,12 @@ describe('channel_members_rhs/channel_members_rhs', () => {
expect(screen.getByText(/channel admins/)).toBeInTheDocument();
});
test('should show alert banner for policy-enforced channels', () => {
test('should show alert banner for policy-enforced private channels with "restricted" wording', () => {
const props = {
...baseProps,
channel: {
...baseProps.channel,
type: 'P' as ChannelType,
policy_enforced: true,
},
};
@ -230,4 +231,28 @@ describe('channel_members_rhs/channel_members_rhs', () => {
expect(screen.getByText('Attribute1: tag1')).toBeInTheDocument();
expect(screen.getByText('Attribute1: tag2')).toBeInTheDocument();
});
test('should show advisory banner for policy-enforced public channels', () => {
const props = {
...baseProps,
channel: {
...baseProps.channel,
type: 'O' as ChannelType,
policy_enforced: true,
},
};
renderWithContext(
<ChannelMembersRHS
{...props as any}
/>,
);
expect(screen.getByText('This channel has recommended members based on user attributes')).toBeInTheDocument();
// Each tag is rendered as "Attribute: value" — same shape as the
// private-channel test above; only the banner copy differs.
expect(screen.getByText('Attribute1: tag1')).toBeInTheDocument();
expect(screen.getByText('Attribute1: tag2')).toBeInTheDocument();
});
});

View file

@ -256,7 +256,10 @@ export default function ChannelMembersRHS({
<AlertBanner
mode='info'
variant='app'
title={formatMessage({
title={channel.type === Constants.OPEN_CHANNEL ? formatMessage({
id: 'channel_members_rhs.policy_recommended_description',
defaultMessage: 'This channel has recommended members based on user attributes',
}) : formatMessage({
id: 'channel_members_rhs.policy_enforced_restrictions',
defaultMessage: 'Channel access is restricted by user attributes',
})}

View file

@ -34,9 +34,11 @@ type Props = {
excludePolicyConstrained?: boolean;
excludeAccessControlPolicyEnforced?: boolean;
excludeGroupConstrained?: boolean;
excludeDefaultChannels?: boolean;
excludeTeamIds?: string[];
excludeTypes?: string[];
teamId?: string;
excludeRemote?: boolean;
customNoOptionsMessage?: React.ReactNode;
isStacked?: boolean;
}
@ -83,6 +85,12 @@ export class ChannelSelectorModal extends React.PureComponent<Props, State> {
} else if (wantsPrivate && !wantsPublic) {
opts.private = true;
}
if (this.props.excludeDefaultChannels) {
opts.exclude_default_channels = true;
}
if (this.props.excludeRemote) {
opts.exclude_remote = true;
}
return opts;
}
@ -158,7 +166,7 @@ export class ChannelSelectorModal extends React.PureComponent<Props, State> {
handlePageChange = (page: number, prevPage: number) => {
if (page > prevPage) {
this.setChannelsLoadingState(true);
this.props.actions.loadChannels(page, CHANNELS_PER_PAGE + 1, this.props.groupID, false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => {
this.props.actions.loadChannels(page, CHANNELS_PER_PAGE + 1, this.props.groupID, this.props.excludeDefaultChannels ?? false, this.props.excludePolicyConstrained, this.props.excludeAccessControlPolicyEnforced).then((response) => {
const newState = [...this.state.channels];
const stateChannelIDs = this.state.channels.map((stateChannel) => stateChannel.id);
(response.data || []).forEach((serverChannel) => {
@ -260,6 +268,13 @@ export class ChannelSelectorModal extends React.PureComponent<Props, State> {
if (this.props.excludeTypes) {
options = options.filter((channel) => this.props.excludeTypes?.indexOf(channel.type) === -1);
}
if (this.props.excludeDefaultChannels) {
// Belt-and-suspenders: the server honors exclude_default_channels on
// the sysadmin search path, but the non-admin (team-scoped) path
// uses AutocompleteChannelsForTeam which ignores it. Filter by the
// canonical default-channel names client-side so both paths agree.
options = options.filter((channel) => channel.name !== Constants.DEFAULT_CHANNEL && channel.name !== Constants.OFFTOPIC_CHANNEL);
}
const values = this.state.values.map((i): ChannelWithTeamDataValue => ({...i, label: i.display_name, value: i.id}));
// Only show custom message when there are no options and user hasn't started searching

View file

@ -50,7 +50,7 @@ exports[`components/channel_settings_modal/ChannelSettingsAccessRulesTab should
<p
class="ChannelSettingsModal__autoSyncDescription"
>
Access rules will prevent unauthorized users from joining, but will not automatically add qualifying members.
Access rules will prevent users who do not match from being added, but qualifying users will not be added automatically.
</p>
</div>
</div>

View file

@ -294,8 +294,10 @@ describe('components/channel_settings_modal/ChannelSettingsAccessRulesTab', () =
initialState,
);
expect(screen.getByRole('heading', {name: 'Access Rules'})).toBeInTheDocument();
expect(screen.getByText('Select user attributes and values as rules to restrict channel membership')).toBeInTheDocument();
// Public channels use membership-oriented copy because ABAC on public
// channels is advisory, not a hard gate.
expect(screen.getByRole('heading', {name: 'Membership Rules'})).toBeInTheDocument();
expect(screen.getByText('Select user attributes and values to describe who should be in this channel. Rules are advisory: anyone can still join.')).toBeInTheDocument();
});
test('should call useChannelAccessControlActions hook', async () => {
@ -497,7 +499,7 @@ describe('components/channel_settings_modal/ChannelSettingsAccessRulesTab', () =
);
expect(screen.getByText('Auto-add members based on access rules')).toBeInTheDocument();
expect(screen.getByText('Access rules will prevent unauthorized users from joining, but will not automatically add qualifying members.')).toBeInTheDocument();
expect(screen.getByText('Access rules will prevent users who do not match from being added, but qualifying users will not be added automatically.')).toBeInTheDocument();
});
test('should show system policy applied message when policies exist but not forcing auto-sync', () => {
@ -522,7 +524,7 @@ describe('components/channel_settings_modal/ChannelSettingsAccessRulesTab', () =
);
expect(screen.getByText('Auto-add members based on access rules')).toBeInTheDocument();
expect(screen.getByText('Access rules will prevent unauthorized users from joining, but will not automatically add qualifying members.')).toBeInTheDocument();
expect(screen.getByText('Access rules will prevent users who do not match from being added, but qualifying users will not be added automatically.')).toBeInTheDocument();
});
test('should toggle auto-sync checkbox when clicked', async () => {
@ -1753,6 +1755,68 @@ describe('components/channel_settings_modal/ChannelSettingsAccessRulesTab', () =
expect(screen.queryByText('Save and apply rules')).not.toBeInTheDocument();
});
test('public channel: saves without membership impact confirmation even when sync would add users', async () => {
const user = userEvent.setup();
mockActions.searchUsers.mockResolvedValue({
data: {
users: [{id: 'user1', username: 'user1'}, {id: 'user2', username: 'user2'}],
total_count: 2,
},
});
mockActions.getChannelMembers.mockResolvedValue({data: []});
const publicChannelProps = {
...baseProps,
channel: TestHelper.getChannelMock({
id: 'channel_id',
name: 'public-channel',
display_name: 'Public Channel',
type: 'O',
}),
};
renderWithContext(
<ChannelSettingsAccessRulesTab {...publicChannelProps}/>,
initialState,
);
await waitFor(() => {
expect(screen.getByTestId('table-editor')).toBeInTheDocument();
});
const onChangeCallback = MockedTableEditor.mock.calls[0][0].onChange;
act(() => {
onChangeCallback('user.department == "engineering"');
});
await waitFor(() => {
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeDisabled();
});
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).toBeChecked();
});
await waitFor(() => {
expect(screen.getByText('You have unsaved changes')).toBeInTheDocument();
});
const saveButton = screen.getByText('Save');
await user.click(saveButton);
await waitFor(() => {
expect(mockActions.saveChannelPolicy).toHaveBeenCalled();
});
expect(screen.queryByText('Review membership impact')).not.toBeInTheDocument();
expect(screen.queryByText('Save and apply rules')).not.toBeInTheDocument();
});
describe('Activity warning logic - comprehensive scenarios', () => {
const stateWithMessages = {
...initialState,

View file

@ -21,6 +21,7 @@ import SaveChangesPanel, {type SaveChangesPanelState} from 'components/widgets/m
import {useChannelAccessControlActions} from 'hooks/useChannelAccessControlActions';
import {useChannelSystemPolicies} from 'hooks/useChannelSystemPolicies';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
@ -243,6 +244,15 @@ function ChannelSettingsAccessRulesTab({
return true; // No expression, skip validation
}
// Public-channel ABAC is advisory: rules can recommend / auto-add members
// but never remove anyone. A non-matching admin cannot lock themselves
// out of a public channel, so the self-inclusion check has no purpose
// here and just blocks legitimate admin workflows (e.g. configuring a
// policy intended for a department the admin doesn't belong to).
if (channel.type === Constants.OPEN_CHANNEL) {
return true;
}
if (!currentUser?.id) {
setFormError(formatMessage({
id: 'channel_settings.access_rules.error.no_current_user',
@ -272,7 +282,7 @@ function ChannelSettingsAccessRulesTab({
return false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser?.id]);
}, [currentUser?.id, channel.type]);
// Check if rules are becoming less restrictive by comparing user matches
const isBecomingLessRestrictive = useCallback(async (oldExpression: string, newExpression: string): Promise<boolean> => {
@ -471,6 +481,28 @@ function ChannelSettingsAccessRulesTab({
const hasRulesNow = expression.trim().length > 0;
const isRemovingAllRules = hadRulesBefore && !hasRulesNow;
// Public channels use advisory membership policies: members are never removed by sync,
// and the impact preview would incorrectly imply removals. Save without confirmation.
if (channel.type === Constants.OPEN_CHANNEL) {
if (autoSyncMembers && isEmptyRulesState) {
setFormError(formatMessage({
id: 'channel_settings.access_rules.expression_required_for_autosync',
defaultMessage: 'Access rules are required when auto-add members is enabled',
}));
return SAVE_RESULT_ERROR;
}
if (expression.trim()) {
const isValid = await validateSelfExclusion(expression);
if (!isValid) {
return SAVE_RESULT_ERROR;
}
}
const success = await performSave();
return success ? SAVE_RESULT_SAVED : SAVE_RESULT_ERROR;
}
// For empty rules state, check if we need to show warning for removing all rules
if (isEmptyRulesState) {
// If removing all rules and channel has history, show activity warning first
@ -554,7 +586,7 @@ function ChannelSettingsAccessRulesTab({
}));
return SAVE_RESULT_ERROR;
}
}, [expression, originalExpression, autoSyncMembers, formatMessage, validateSelfExclusion, calculateMembershipChanges, performSave, isEmptyRulesState, channelMessageCount, isBecomingLessRestrictive]);
}, [expression, originalExpression, autoSyncMembers, formatMessage, validateSelfExclusion, calculateMembershipChanges, performSave, isEmptyRulesState, channelMessageCount, isBecomingLessRestrictive, channel.type]);
// Prevent duplicate saves with immediate response
const saveInProgressRef = useRef(false);
@ -681,10 +713,19 @@ function ChannelSettingsAccessRulesTab({
<div className='ChannelSettingsModal__accessRulesHeader'>
<h3 className='ChannelSettingsModal__accessRulesTitle'>
{formatMessage({id: 'channel_settings.access_rules.title', defaultMessage: 'Access Rules'})}
{channel.type === Constants.OPEN_CHANNEL ? formatMessage({
id: 'channel_settings.access_rules.title_public',
defaultMessage: 'Membership Rules',
}) : formatMessage({
id: 'channel_settings.access_rules.title',
defaultMessage: 'Access Rules',
})}
</h3>
<p className='ChannelSettingsModal__accessRulesSubtitle'>
{formatMessage({
{channel.type === Constants.OPEN_CHANNEL ? formatMessage({
id: 'channel_settings.access_rules.subtitle_public',
defaultMessage: 'Select user attributes and values to describe who should be in this channel. Rules are advisory: anyone can still join.',
}) : formatMessage({
id: 'channel_settings.access_rules.subtitle',
defaultMessage: 'Select user attributes and values as rules to restrict channel membership',
})}
@ -704,7 +745,13 @@ function ChannelSettingsAccessRulesTab({
actions={actions}
enableUserManagedAttributes={accessControlSettings?.EnableUserManagedAttributes || false}
isSystemAdmin={isSystemAdmin}
validateExpressionAgainstRequester={actions.validateExpressionAgainstRequester}
// Suppress the live "you would be excluded" banner on
// public channels — public-channel ABAC is advisory and
// can never lock the admin out, so the warning is
// misleading. The save-time guard is also skipped in
// validateSelfExclusion above.
validateExpressionAgainstRequester={channel.type === Constants.OPEN_CHANNEL ? undefined : actions.validateExpressionAgainstRequester}
/>
</div>
)}
@ -754,16 +801,30 @@ function ChannelSettingsAccessRulesTab({
</div>
<p className='ChannelSettingsModal__autoSyncDescription'>
{(() => {
const isPublic = channel.type === Constants.OPEN_CHANNEL;
if (autoSyncMembers) {
if (isPublic) {
return formatMessage({
id: 'channel_settings.access_rules.auto_sync_enabled_public_description',
defaultMessage: 'Qualifying users are automatically added as members. Members can still leave on their own — no one is removed based on these rules.',
});
}
return formatMessage({
id: 'channel_settings.access_rules.auto_sync_enabled_description',
defaultMessage: 'Users who match the configured attribute values will be automatically added as members and those who no longer match will be removed.',
defaultMessage: 'Qualifying users are automatically added as members, and members who no longer match will be removed.',
});
}
if (isPublic) {
return formatMessage({
id: 'channel_settings.access_rules.auto_sync_disabled_public_description',
defaultMessage: 'This channel will appear under "Recommended channels" for users who match the rules. Anyone can still join freely.',
});
}
return formatMessage({
id: 'channel_settings.access_rules.auto_sync_disabled_description',
defaultMessage: 'Access rules will prevent unauthorized users from joining, but will not automatically add qualifying members.',
defaultMessage: 'Access rules will prevent users who do not match from being added, but qualifying users will not be added automatically.',
});
})()}
</p>

View file

@ -500,6 +500,33 @@ describe('ChannelSettingsInfoTab', () => {
expect(privateButton).toHaveClass('selected');
});
it('should disable public/private selector when channel has membership policy enforced', async () => {
mockConvertToPrivatePermission = true;
mockConvertToPublicPermission = true;
const channelWithPolicy = {
...mockChannel,
policy_enforced: true,
};
renderWithContext(
<ChannelSettingsInfoTab
{...baseProps}
channel={channelWithPolicy}
/>,
);
const publicButton = screen.getByRole('button', {name: /Public Channel/});
const privateButton = screen.getByRole('button', {name: /Private Channel/});
expect(publicButton).toHaveClass('disabled');
expect(privateButton).toHaveClass('disabled');
await userEvent.hover(publicButton);
expect(
await screen.findByText(/This channel has a membership policy applied/i),
).toBeInTheDocument();
});
it('should show ConvertConfirmModal when converting from public to private', async () => {
mockConvertToPrivatePermission = true;

View file

@ -90,6 +90,15 @@ function ChannelSettingsInfoTab({
const currentCategoryName = useSelector((state: GlobalState) => getChannelManagedCategoryName(state, channel.id));
// Must stay aligned with server `UpdateChannel` when an ABAC membership policy is enforced.
const channelTypeLockedByMembershipPolicy = Boolean(channel.policy_enforced);
const channelTypeLockTooltip = channelTypeLockedByMembershipPolicy ?
formatMessage({
id: 'channel_settings.policy_enforced.cannot_change_channel_type',
defaultMessage: 'This channel has a membership policy applied. Remove the policy before changing between public and private.',
}) :
undefined;
const [managedCategoryName, setManagedCategoryName] = useState(currentCategoryName);
const [serverCategoryName, setServerCategoryName] = useState(currentCategoryName);
@ -168,6 +177,10 @@ function ChannelSettingsInfoTab({
}, [dispatch, shouldShowPreviewHeader]);
const handleChannelTypeChange = (type: ChannelType) => {
if (channelTypeLockedByMembershipPolicy) {
return;
}
// Never allow conversion from private to public, regardless of permissions
if (channel.type === Constants.PRIVATE_CHANNEL && type === Constants.OPEN_CHANNEL) {
return;
@ -434,12 +447,14 @@ function ChannelSettingsInfoTab({
description: formatMessage({id: 'channel_modal.type.public.description', defaultMessage: 'Anyone can join'}),
// Always disable public button if current channel is private, regardless of permissions
disabled: channel.type === Constants.PRIVATE_CHANNEL || !canConvertToPublic,
disabled: channel.type === Constants.PRIVATE_CHANNEL || !canConvertToPublic || channelTypeLockedByMembershipPolicy,
tooltip: channelTypeLockTooltip,
}}
privateButtonProps={{
title: formatMessage({id: 'channel_modal.type.private.title', defaultMessage: 'Private Channel'}),
description: formatMessage({id: 'channel_modal.type.private.description', defaultMessage: 'Only invited members'}),
disabled: !canConvertToPrivate,
disabled: !canConvertToPrivate || channelTypeLockedByMembershipPolicy,
tooltip: channelTypeLockTooltip,
}}
onChange={handleChannelTypeChange}
/>

View file

@ -366,9 +366,9 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The Access Control tab should be visible
// The Membership Policy tab should be visible
expect(screen.getByRole('tab', {name: 'access_rules'})).toBeInTheDocument();
expect(screen.getByText('Access Control')).toBeInTheDocument();
expect(screen.getByText('Membership Policy')).toBeInTheDocument();
});
it('should not show Access Control tab for private channel when user lacks permission', async () => {
@ -384,12 +384,12 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The Access Control tab should not be visible
// The Membership Policy tab should not be visible
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Access Control')).not.toBeInTheDocument();
expect(screen.queryByText('Membership Policy')).not.toBeInTheDocument();
});
it('should not show Access Control tab for public channel even with permission', async () => {
it('should show Access Control tab for public channel when user has permission', async () => {
mockManageChannelAccessRulesPermission = true;
const testState = makeTestState();
@ -401,9 +401,9 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The Access Control tab should not be visible for public channels
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Access Control')).not.toBeInTheDocument();
// Public channels are eligible for ABAC policies (advisory / auto-add),
// so the Access Rules tab should be available when the user can manage them.
expect(screen.getByRole('tab', {name: 'access_rules'})).toBeInTheDocument();
});
it('should not show Access Control tab for public channel without permission', async () => {
@ -418,9 +418,31 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The Access Control tab should not be visible
// The Membership Policy tab should not be visible
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Access Control')).not.toBeInTheDocument();
expect(screen.queryByText('Membership Policy')).not.toBeInTheDocument();
});
it.each([
['town-square', 'town-square'],
['off-topic', 'off-topic'],
])('should not show Access Control tab on %s default channel even with permission', async (_label, channelName) => {
// The server rejects ABAC policies on default channels via
// ValidateChannelEligibilityForAccessControl, so the tab would only
// let the user assemble rules they can never save.
mockManageChannelAccessRulesPermission = true;
const testState = makeTestState();
testState.entities.channels.channels[channelId].name = channelName;
renderWithContext(<ChannelSettingsModal {...baseProps}/>, testState);
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Membership Policy')).not.toBeInTheDocument();
});
it('should be able to navigate to Access Control tab when visible', async () => {
@ -448,7 +470,7 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByText('Access Rules Tab Content')).toBeInTheDocument();
});
it('should show correct tab label as "Access Control"', async () => {
it('should show correct tab label as "Membership Policy"', async () => {
mockManageChannelAccessRulesPermission = true;
const testState = makeTestState();
@ -463,25 +485,24 @@ describe('ChannelSettingsModal', () => {
// Verify the tab shows the correct label
const accessControlTab = screen.getByRole('tab', {name: 'access_rules'});
expect(accessControlTab).toHaveTextContent('Access Control');
expect(accessControlTab).toHaveTextContent('Membership Policy');
});
it('should show Access Control tab for default channel if private and user has permission', async () => {
it('should not show Membership Policy tab for shared channels', async () => {
mockManageChannelAccessRulesPermission = true;
const testState = makeTestState();
testState.entities.channels.channels[channelId].name = 'town-square';
testState.entities.channels.channels[channelId].type = General.PRIVATE_CHANNEL;
testState.entities.channels.channels[channelId].shared = true;
renderWithContext(<ChannelSettingsModal {...baseProps}/>, testState);
// Wait for the sidebar to load
await waitFor(() => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// Access Control tab visibility is not restricted for default channel - only depends on channel type and permission
expect(screen.getByRole('tab', {name: 'access_rules'})).toBeInTheDocument();
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Membership Policy')).not.toBeInTheDocument();
});
it('should not show Access Control tab for group-constrained private channel even with permission', async () => {
@ -498,9 +519,9 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The Access Control tab should not be visible for group-constrained channels
// The Membership Policy tab should not be visible for group-constrained channels
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Access Control')).not.toBeInTheDocument();
expect(screen.queryByText('Membership Policy')).not.toBeInTheDocument();
});
it('should not show Access Control tab for group-constrained private channel without permission', async () => {
@ -517,9 +538,9 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The Access Control tab should not be visible (for multiple reasons)
// The Membership Policy tab should not be visible (for multiple reasons)
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Access Control')).not.toBeInTheDocument();
expect(screen.queryByText('Membership Policy')).not.toBeInTheDocument();
});
it('should not show Access Control tab for group-constrained public channel', async () => {
@ -535,9 +556,9 @@ describe('ChannelSettingsModal', () => {
expect(screen.getByTestId('settings-sidebar')).toBeInTheDocument();
});
// The Access Control tab should not be visible (for multiple reasons: public + group-constrained)
// The Membership Policy tab should not be visible (for multiple reasons: public + group-constrained)
expect(screen.queryByRole('tab', {name: 'access_rules'})).not.toBeInTheDocument();
expect(screen.queryByText('Access Control')).not.toBeInTheDocument();
expect(screen.queryByText('Membership Policy')).not.toBeInTheDocument();
});
});

View file

@ -134,7 +134,14 @@ function ChannelSettingsModal({channelId, isOpen, onExited, focusOriginElement}:
const channelAdminABACControlEnabled = useSelector(isChannelAccessControlEnabled);
const shouldShowAccessRulesTab = channelAdminABACControlEnabled && canManageChannelAccessRules && channel.type === Constants.PRIVATE_CHANNEL && !channel.group_constrained;
const isPolicyEligibleChannelType = channel.type === Constants.PRIVATE_CHANNEL || channel.type === Constants.OPEN_CHANNEL;
// Default channels (town-square / off-topic) cannot have ABAC policies —
// ValidateChannelEligibilityForAccessControl rejects them on the server, so
// showing the Membership Policy tab here would only let the user assemble
// rules they can never save.
const isDefaultChannel = channel.name === Constants.DEFAULT_CHANNEL || channel.name === Constants.OFFTOPIC_CHANNEL;
const shouldShowAccessRulesTab = channelAdminABACControlEnabled && canManageChannelAccessRules && isPolicyEligibleChannelType && !channel.group_constrained && !isDefaultChannel && !channel.shared;
const shouldShowArchiveTab = channel.name !== Constants.DEFAULT_CHANNEL &&
((channel.type === Constants.PRIVATE_CHANNEL && canArchivePrivateChannels) ||
@ -285,9 +292,9 @@ function ChannelSettingsModal({channelId, isOpen, onExited, focusOriginElement}:
},
{
name: ChannelSettingsTabs.ACCESS_RULES,
uiName: formatMessage({id: 'channel_settings.tab.access_control', defaultMessage: 'Access Control'}),
uiName: formatMessage({id: 'channel_settings.tab.membership_policy', defaultMessage: 'Membership Policy'}),
icon: 'icon icon-shield-outline',
iconTitle: formatMessage({id: 'generic_icons.access_rules', defaultMessage: 'Access Rules Icon'}),
iconTitle: formatMessage({id: 'generic_icons.access_rules', defaultMessage: 'Membership Policy Icon'}),
display: shouldShowAccessRulesTab,
},
{

View file

@ -43,6 +43,7 @@ interface Props extends WrappedComponentProps {
rememberHideJoinedChannelsChecked: boolean;
loading?: boolean;
channelsMemberCount?: Record<string, number>;
showRecommendedFilter?: boolean;
}
type State = {
@ -310,6 +311,14 @@ export class SearchableChannelList extends React.PureComponent<Props, State> {
defaultMessage={'No public channels'}
/>
);
case Filter.Recommended:
return (
<FormattedMessage
id={'more_channels.noRecommended'}
tagName='strong'
defaultMessage={'No recommended channels'}
/>
);
default:
return (
<FormattedMessage
@ -343,6 +352,13 @@ export class SearchableChannelList extends React.PureComponent<Props, State> {
defaultMessage='Channel Type: Private'
/>
);
case Filter.Recommended:
return (
<FormattedMessage
id='more_channels.show_recommended_channels'
defaultMessage='Recommended channels'
/>
);
default:
return (
<FormattedMessage
@ -486,6 +502,26 @@ export class SearchableChannelList extends React.PureComponent<Props, State> {
/>,
];
if (this.props.showRecommendedFilter) {
channelDropdownItems.push(
<Menu.Separator key='channelsMoreDropdownRecommendedSeparator'/>,
<Menu.Item
key='channelsMoreDropdownRecommended'
id='channelsMoreDropdownRecommended'
onClick={() => this.filterChange(Filter.Recommended)}
leadingElement={<GlobeCheckedIcon size={16}/>}
labels={
<FormattedMessage
id='suggestion.recommended'
defaultMessage='Recommended channels'
/>
}
trailingElements={this.props.filter === Filter.Recommended ? checkIcon : null}
aria-label={this.props.intl.formatMessage({id: 'suggestion.recommended', defaultMessage: 'Recommended channels'})}
/>,
);
}
channelDropdownItems.push(
<Menu.Separator key='channelsMoreDropdownSeparator'/>,
<Menu.Item

View file

@ -93,8 +93,11 @@ describe('components/system_policy_indicator/SystemPolicyIndicator', () => {
);
expect(screen.getByText('Confidential DS-BP')).toBeInTheDocument();
expect(screen.getByText(/System access policy applied to this channel/)).toBeInTheDocument();
expect(screen.getByText(/Any custom access rules you set here will be applied in addition to this policy/)).toBeInTheDocument();
expect(screen.getByText(/System membership policy applied to this channel/)).toBeInTheDocument();
// Body uses singular "a system-level membership policy" to agree with the title.
expect(screen.getByText(/This channel has a system-level membership policy applied/)).toBeInTheDocument();
expect(screen.getByText(/Any custom membership rules you set here will be applied in addition to this policy/)).toBeInTheDocument();
// Check for alert banner structure
expect(screen.getByTestId('system-policy-indicator')).toBeInTheDocument();
@ -109,11 +112,11 @@ describe('components/system_policy_indicator/SystemPolicyIndicator', () => {
expect(screen.getByText('Confidential DS-BP')).toBeInTheDocument();
expect(screen.getByText('Northern Command Filter')).toBeInTheDocument();
expect(screen.getByText(/Multiple system access policies applied to this channel/)).toBeInTheDocument();
expect(screen.getByText(/Any custom access rules you set here will be applied in addition to this policy/)).toBeInTheDocument();
expect(screen.getByText(/Multiple system membership policies applied to this channel/)).toBeInTheDocument();
expect(screen.getByText(/Any custom membership rules you set here will be applied in addition to these policies/)).toBeInTheDocument();
// Check that both policy names appear in the description
const description = screen.getByText(/This channel has system-level access policies applied/);
const description = screen.getByText(/This channel has system-level membership policies applied/);
expect(description).toBeInTheDocument();
});
@ -179,9 +182,9 @@ describe('components/system_policy_indicator/SystemPolicyIndicator', () => {
);
// Should show basic message but not the detailed policy list or names
expect(screen.getByText(/This channel has system-level access policies applied/)).toBeInTheDocument();
expect(screen.getByText(/This channel has system-level membership policies applied/)).toBeInTheDocument();
expect(screen.queryByText('Confidential DS-BP')).not.toBeInTheDocument();
expect(screen.queryByText(/System access policy applied to this channel/)).not.toBeInTheDocument();
expect(screen.queryByText(/System membership policy applied to this channel/)).not.toBeInTheDocument();
});
test('should handle different resource types in compact variant', () => {
@ -194,7 +197,7 @@ describe('components/system_policy_indicator/SystemPolicyIndicator', () => {
initialState,
);
expect(screen.getByText(/This team has system-level access policy applied/)).toBeInTheDocument();
expect(screen.getByText(/This team has system-level membership policy applied/)).toBeInTheDocument();
});
test('should handle file resource type in compact variant', () => {
@ -207,6 +210,7 @@ describe('components/system_policy_indicator/SystemPolicyIndicator', () => {
initialState,
);
// Files keep the access-oriented wording since users aren't members of files.
expect(screen.getByText(/This file has system-level access policy applied/)).toBeInTheDocument();
});
@ -220,7 +224,7 @@ describe('components/system_policy_indicator/SystemPolicyIndicator', () => {
);
expect(screen.queryByText('Confidential DS-BP')).not.toBeInTheDocument();
expect(screen.getByText(/System access policy applied to this channel/)).toBeInTheDocument();
expect(screen.getByText(/System membership policy applied to this channel/)).toBeInTheDocument();
});
// Test more button functionality

View file

@ -104,7 +104,36 @@ const SystemPolicyIndicator: React.FC<SystemPolicyIndicatorProps> = ({
);
}, [showPolicyNames, safePolicies, getPolicyDisplayName, handleMorePoliciesClick, handleKeyDown]);
const policyListSuffix = useMemo(() => {
if (!showPolicyNames || safePolicies.length === 0) {
return null;
}
return (
<>
{': '}
{renderPolicyList()}
</>
);
}, [showPolicyNames, safePolicies.length, renderPolicyList]);
// Channels (and teams) use membership-oriented wording because the policy
// governs who is or becomes a member. Files retain "access" wording — a
// user doesn't become a "member" of a file.
const usesMembershipWording = resourceType === 'channel' || resourceType === 'team';
const renderCompactMessage = useCallback(() => {
if (usesMembershipWording) {
return (
<FormattedMessage
id='system_policy_indicator.base_message_membership'
defaultMessage='This {resourceType} has system-level membership {policyText} applied'
values={{
resourceType,
policyText: hasMultiplePolicies ? 'policies' : 'policy',
}}
/>
);
}
return (
<FormattedMessage
id='system_policy_indicator.base_message'
@ -115,22 +144,70 @@ const SystemPolicyIndicator: React.FC<SystemPolicyIndicatorProps> = ({
}}
/>
);
}, [resourceType, hasMultiplePolicies]);
}, [resourceType, hasMultiplePolicies, usesMembershipWording]);
const renderDetailedMessage = useCallback(() => {
const title = hasMultiplePolicies ? (
<FormattedMessage
id='system_policy_indicator.multiple_policies_title'
defaultMessage='Multiple system access policies applied to this {resourceType}'
values={{resourceType}}
/>
) : (
<FormattedMessage
id='system_policy_indicator.single_policy_title'
defaultMessage='System access policy applied to this {resourceType}'
values={{resourceType}}
/>
);
let title: JSX.Element;
if (usesMembershipWording) {
title = hasMultiplePolicies ? (
<FormattedMessage
id='system_policy_indicator.multiple_membership_policies_title'
defaultMessage='Multiple system membership policies applied to this {resourceType}'
values={{resourceType}}
/>
) : (
<FormattedMessage
id='system_policy_indicator.single_membership_policy_title'
defaultMessage='System membership policy applied to this {resourceType}'
values={{resourceType}}
/>
);
} else {
title = hasMultiplePolicies ? (
<FormattedMessage
id='system_policy_indicator.multiple_policies_title'
defaultMessage='Multiple system access policies applied to this {resourceType}'
values={{resourceType}}
/>
) : (
<FormattedMessage
id='system_policy_indicator.single_policy_title'
defaultMessage='System access policy applied to this {resourceType}'
values={{resourceType}}
/>
);
}
let description: JSX.Element;
if (usesMembershipWording) {
description = hasMultiplePolicies ? (
<FormattedMessage
id='system_policy_indicator.description_with_membership_policies'
defaultMessage='This {resourceType} has system-level membership policies applied{policySuffix}. Any custom membership rules you set here will be applied in addition to these policies.'
values={{resourceType, policySuffix: policyListSuffix}}
/>
) : (
<FormattedMessage
id='system_policy_indicator.description_with_membership_policy'
defaultMessage='This {resourceType} has a system-level membership policy applied{policySuffix}. Any custom membership rules you set here will be applied in addition to this policy.'
values={{resourceType, policySuffix: policyListSuffix}}
/>
);
} else {
description = hasMultiplePolicies ? (
<FormattedMessage
id='system_policy_indicator.description_with_policies'
defaultMessage='This {resourceType} has system-level access policies applied{policySuffix}. Any custom access rules you set here will be applied in addition to these policies.'
values={{resourceType, policySuffix: policyListSuffix}}
/>
) : (
<FormattedMessage
id='system_policy_indicator.description_with_policy'
defaultMessage='This {resourceType} has a system-level access policy applied{policySuffix}. Any custom access rules you set here will be applied in addition to this policy.'
values={{resourceType, policySuffix: policyListSuffix}}
/>
);
}
return (
<>
@ -146,18 +223,11 @@ const SystemPolicyIndicator: React.FC<SystemPolicyIndicatorProps> = ({
role='region'
aria-label='System policy details'
>
<FormattedMessage
id='system_policy_indicator.description_with_policies'
defaultMessage='This {resourceType} has system-level access policies applied: {policyList}. Any custom access rules you set here will be applied in addition to this policy.'
values={{
resourceType,
policyList: renderPolicyList(),
}}
/>
{description}
</div>
</>
);
}, [hasMultiplePolicies, resourceType, renderPolicyList]);
}, [hasMultiplePolicies, resourceType, policyListSuffix, usesMembershipWording]);
const renderMessage = useCallback(() => {
if (variant === 'compact') {

View file

@ -10,14 +10,57 @@ import './team_policy_confirmation_modal.scss';
type Props = {
channelsAffected: number;
publicChannelsAffected?: number;
privateChannelsAffected?: number;
onExited: () => void;
onConfirm: () => void;
saving?: boolean;
}
export default function TeamPolicyConfirmationModal({channelsAffected, onExited, onConfirm, saving}: Props) {
export default function TeamPolicyConfirmationModal({channelsAffected, publicChannelsAffected = 0, privateChannelsAffected = 0, onExited, onConfirm, saving}: Props) {
const {formatMessage} = useIntl();
const hasMix = publicChannelsAffected > 0 && privateChannelsAffected > 0;
const hasOnlyPublic = publicChannelsAffected > 0 && privateChannelsAffected === 0;
let body: React.ReactNode;
if (hasMix) {
body = (
<FormattedMessage
id='team_settings.policy_editor.confirmation.body_mixed'
defaultMessage='This policy is applied to <b>{count} assigned {count, plural, one {channel} other {channels}}</b> of mixed types. For <b>{privateCount, plural, one {# private channel} other {# private channels}}</b>, matching users will be granted access and non-matching members will be removed. For <b>{publicCount, plural, one {# public channel} other {# public channels}}</b>, the policy is advisory: matching users will be recommended to join the channel and auto-added when enabled, but no existing members will ever be removed.'
values={{
count: channelsAffected,
publicCount: publicChannelsAffected,
privateCount: privateChannelsAffected,
b: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
}}
/>
);
} else if (hasOnlyPublic) {
body = (
<FormattedMessage
id='team_settings.policy_editor.confirmation.body_public'
defaultMessage='This policy will be applied to <b>{count} assigned {count, plural, one {public channel} other {public channels}}</b>. Matching users will see {count, plural, one {this channel} other {these channels}} as recommendations, and will be auto-added when auto-add is enabled. No existing members will be removed.'
values={{
count: channelsAffected,
b: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
}}
/>
);
} else {
body = (
<FormattedMessage
id='team_settings.policy_editor.confirmation.body'
defaultMessage='This policy will grant users with matching attribute values access to <b>{count} assigned {count, plural, one {channel} other {channels}}</b> and remove users without these attribute values.'
values={{
count: channelsAffected,
b: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
}}
/>
);
}
return (
<GenericModal
className='TeamPolicyConfirmationModal'
@ -53,16 +96,7 @@ export default function TeamPolicyConfirmationModal({channelsAffected, onExited,
}
>
<div className='TeamPolicyConfirmationModal__body'>
<p>
<FormattedMessage
id='team_settings.policy_editor.confirmation.body'
defaultMessage='This policy will grant users with matching attribute values access to <b>{count} assigned {count, plural, one {channel} other {channels}}</b> and remove users without these attribute values.'
values={{
count: channelsAffected,
b: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
}}
/>
</p>
<p>{body}</p>
<p>
<FormattedMessage
id='team_settings.policy_editor.confirmation.question'

View file

@ -106,7 +106,7 @@ describe('TeamPolicyEditor', () => {
test('should not show delete section for new policy', async () => {
renderWithContext(<TeamPolicyEditor {...defaultProps}/>);
await waitFor(() => {
expect(screen.getByText('Access rules')).toBeInTheDocument();
expect(screen.getByText('Membership rules')).toBeInTheDocument();
});
expect(screen.queryByText('Delete policy')).not.toBeInTheDocument();
});

View file

@ -24,6 +24,7 @@ import SaveChangesPanel from 'components/widgets/modals/components/save_changes_
import type {SaveChangesPanelState} from 'components/widgets/modals/components/save_changes_panel';
import {useChannelAccessControlActions} from 'hooks/useChannelAccessControlActions';
import Constants from 'utils/constants';
import TeamPolicyConfirmationModal from './team_policy_confirmation_modal';
@ -85,6 +86,12 @@ export default function TeamPolicyEditor({
const [policyActiveStatusChanges, setPolicyActiveStatusChanges] = useState<PolicyActiveStatus[]>([]);
const [channelsCount, setChannelsCount] = useState(0);
const [savedChannelIds, setSavedChannelIds] = useState<string[]>([]);
// Map of saved channelId → channel type ('O' | 'P' | ...). Used to compute
// how many public vs. private channels a save will affect, so the
// confirmation modal can pick the right messaging. Only 'O'/'P' can ever
// be assigned to a policy, but we key by ID so removals map cleanly.
const [savedChannelTypes, setSavedChannelTypes] = useState<Record<string, string>>({});
const [addChannelOpen, setAddChannelOpen] = useState(false);
// Attribute state
@ -135,8 +142,10 @@ export default function TeamPolicyEditor({
}
});
actions.searchChannels(policyId, '', {per_page: 1000}).then((result) => {
const channels: ChannelWithTeamData[] = result.data?.channels || [];
setChannelsCount(result.data?.total_count || 0);
setSavedChannelIds((result.data?.channels || []).map((ch: ChannelWithTeamData) => ch.id));
setSavedChannelIds(channels.map((ch) => ch.id));
setSavedChannelTypes(Object.fromEntries(channels.map((ch) => [ch.id, ch.type])));
});
}, [policyId]);// eslint-disable-line react-hooks/exhaustive-deps
@ -194,6 +203,51 @@ export default function TeamPolicyEditor({
return ((channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length) > 0;
}, [channelsCount, channelChanges]);
// True iff the policy will be applied to at least one private channel after
// pending changes are committed. Public-channel ABAC is advisory and cannot
// lock anyone out, so the self-inclusion guard only matters when a private
// channel is in scope.
const hasPrivateChannelInScope = useCallback(() => {
for (const [id, type] of Object.entries(savedChannelTypes)) {
if (channelChanges.removed[id]) {
continue;
}
if (type === Constants.PRIVATE_CHANNEL) {
return true;
}
}
for (const ch of Object.values(channelChanges.added)) {
if (ch.type === Constants.PRIVATE_CHANNEL) {
return true;
}
}
return false;
}, [savedChannelTypes, channelChanges]);
const confirmationChannelCounts = useMemo(() => {
let publicCount = 0;
let privateCount = 0;
for (const [id, type] of Object.entries(savedChannelTypes)) {
if (channelChanges.removed[id]) {
continue;
}
if (type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
for (const ch of Object.values(channelChanges.added)) {
if (ch.type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (ch.type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
const channelsAffected = (channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length;
return {publicCount, privateCount, channelsAffected};
}, [savedChannelTypes, channelChanges, channelsCount]);
const validateForm = useCallback(async () => {
if (policyName.length === 0) {
setFormError(formatMessage({id: 'admin.access_control.policy.edit_policy.error.name_required', defaultMessage: 'Please add a name to the policy'}));
@ -216,8 +270,11 @@ export default function TeamPolicyEditor({
return false;
}
// Validate self-inclusion: delegated admin must satisfy the policy's rules
if (expression.trim()) {
// Validate self-inclusion: delegated admin must satisfy the policy's rules.
// Skipped when the policy applies only to public channels — those are
// advisory under ABAC and can't kick anyone out, so a non-matching admin
// is never at risk of locking themselves out.
if (expression.trim() && hasPrivateChannelInScope()) {
try {
const result = await abacActions.validateExpressionAgainstRequester(expression);
if (!result.data?.requester_matches) {
@ -226,14 +283,14 @@ export default function TeamPolicyEditor({
return false;
}
} catch {
setFormError(formatMessage({id: 'team_settings.policy_editor.error.validation_failed', defaultMessage: 'Failed to validate access rules. Please try again.'}));
setFormError(formatMessage({id: 'team_settings.policy_editor.error.validation_failed', defaultMessage: 'Failed to validate membership rules. Please try again.'}));
setSaveChangesPanelState(SAVE_RESULT_ERROR);
return false;
}
}
return true;
}, [policyName, expression, hasChannels, formatMessage, abacActions]);
}, [policyName, expression, hasChannels, hasPrivateChannelInScope, formatMessage, abacActions]);
const handleSave = useCallback(async () => {
if (!await validateForm()) {
@ -439,13 +496,13 @@ export default function TeamPolicyEditor({
<h4 className='TeamPolicyEditor__section-title'>
<FormattedMessage
id='team_settings.policy_editor.rules_title'
defaultMessage='Access rules'
defaultMessage='Membership rules'
/>
</h4>
<p className='TeamPolicyEditor__section-subtitle'>
<FormattedMessage
id='team_settings.policy_editor.rules_subtitle'
defaultMessage='Select user attributes and values as rules to restrict access'
defaultMessage='Select user attributes and values as rules for membership'
/>
</p>
</div>
@ -461,7 +518,12 @@ export default function TeamPolicyEditor({
actions={abacActions}
teamId={teamId}
isSystemAdmin={false}
validateExpressionAgainstRequester={abacActions.validateExpressionAgainstRequester}
// Suppress the live "you would be excluded" banner when
// the policy applies only to public channels — there's
// nothing to be excluded from in advisory mode, so the
// warning is misleading.
validateExpressionAgainstRequester={hasPrivateChannelInScope() ? abacActions.validateExpressionAgainstRequester : undefined}
/>
</div>
@ -553,9 +615,11 @@ export default function TeamPolicyEditor({
onChannelsSelected={addToNewChannels}
groupID=''
alreadySelected={[...savedChannelIds, ...Object.keys(channelChanges.added)].filter((id) => !channelChanges.removed[id])}
excludeTypes={['O', 'D', 'G']}
excludeTypes={['D', 'G']}
excludeGroupConstrained={true}
excludeDefaultChannels={true}
teamId={teamId}
excludeRemote={Boolean(teamId)}
isStacked={true}
/>
)}
@ -564,7 +628,9 @@ export default function TeamPolicyEditor({
<TeamPolicyConfirmationModal
onExited={() => setShowConfirmationModal(false)}
onConfirm={handleSave}
channelsAffected={(channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length}
channelsAffected={confirmationChannelCounts.channelsAffected}
publicChannelsAffected={confirmationChannelCounts.publicCount}
privateChannelsAffected={confirmationChannelCounts.privateCount}
saving={saving}
/>
)}

View file

@ -123,7 +123,7 @@ const TeamSettingsModal = ({isOpen, onExited, focusOriginElement}: Props) => {
name: 'access_policies',
uiName: formatMessage({id: 'team_settings_modal.accessPoliciesTab', defaultMessage: 'Membership Policies'}),
icon: 'icon icon-shield-outline',
iconTitle: formatMessage({id: 'generic_icons.access_rules', defaultMessage: 'Access Rules Icon'}),
iconTitle: formatMessage({id: 'generic_icons.access_rules', defaultMessage: 'Membership Policy Icon'}),
display: abacEnabled && canManageTeamAccessRules,
},
];

View file

@ -283,24 +283,24 @@
"admin.access_control.jobTable.syncResults.canceled.title": "Job Canceled",
"admin.access_control.policies.add_policy": "Add policy",
"admin.access_control.policies.applies_to": "Applies to",
"admin.access_control.policies.description": "Create policies containing attribute based access rules and the resources they apply to.",
"admin.access_control.policies.description": "Create policies containing attribute-based membership rules and the channels they apply to.",
"admin.access_control.policies.menu.aria_label": "Policy actions menu",
"admin.access_control.policies.name": "Name",
"admin.access_control.policies.refresh": "Refresh list",
"admin.access_control.policies.resources.channels": "{count, number} {count, plural, one {channel} other {channels}}",
"admin.access_control.policies.resources.none": "None",
"admin.access_control.policies.title": "Access Control Policies",
"admin.access_control.policies.title": "Membership Policies",
"admin.access_control.policy.channel_list.autoAddHeader": "Auto-add members",
"admin.access_control.policy.channel_list.autoAddTooltip.line1": "Toggle to auto-add members who meet all access requirements",
"admin.access_control.policy.channel_list.autoAddTooltip.line2": "Channel administrators can modify this setting",
"admin.access_control.policy.channel_list.off": "Off",
"admin.access_control.policy.channel_list.on": "On",
"admin.access_control.policy.channels_affected": "Are you sure you want to save and apply the access control policy?",
"admin.access_control.policy.edit_policy.access_rules.subtitle": "Select user attributes and values as rules to restrict channel membership.",
"admin.access_control.policy.edit_policy.access_rules.title": "Attribute-based access rules",
"admin.access_control.policy.channels_affected": "Are you sure you want to save and apply the membership policy?",
"admin.access_control.policy.edit_policy.access_rules.subtitle": "Select user attributes and values as rules to determine who should be in the channels this policy applies to.",
"admin.access_control.policy.edit_policy.access_rules.title": "Attribute-based membership rules",
"admin.access_control.policy.edit_policy.channel_selector.addChannels": "Add channels",
"admin.access_control.policy.edit_policy.channel_selector.remove": "Remove",
"admin.access_control.policy.edit_policy.channel_selector.subtitle": "Add channels that this attribute-based access policy will apply to.",
"admin.access_control.policy.edit_policy.channel_selector.subtitle": "Add channels that this membership policy will apply to.",
"admin.access_control.policy.edit_policy.channel_selector.title": "Assigned channels",
"admin.access_control.policy.edit_policy.complex_expression_tooltip": "Complex expression detected. Simple expressions editor is not available at the moment.",
"admin.access_control.policy.edit_policy.delete_confirmation.confirm_button": "Delete Policy",
@ -317,22 +317,31 @@
"admin.access_control.policy.edit_policy.error.name_required": "Please add a name to the policy",
"admin.access_control.policy.edit_policy.error.unassign_channels": "Error unassigning channels: {error}",
"admin.access_control.policy.edit_policy.error.update_active_status": "Error updating policy active status: {error}",
"admin.access_control.policy.edit_policy.no_private_channels": "There are no private channels available to add to this policy.",
"admin.access_control.policy.edit_policy.no_channels_available": "There are no channels available to add to this policy.",
"admin.access_control.policy.edit_policy.no_usable_attributes_tooltip": "Please configure user attributes to use the editor.",
"admin.access_control.policy.edit_policy.notice.button": "Configure user attributes",
"admin.access_control.policy.edit_policy.notice.text": "You havent configured any user attributes yet. Attribute-Based Access Control requires user attributes that are either synced from an external system (like LDAP or SAML) or manually configured and enabled on this server. To start using attribute based access, please configure user attributes in System Attributes.",
"admin.access_control.policy.edit_policy.notice.title": "Please add user attributes and values to use Attribute-Based Access Control",
"admin.access_control.policy.edit_policy.policyName": "Access control policy name:",
"admin.access_control.policy.edit_policy.policyName": "Membership policy name:",
"admin.access_control.policy.edit_policy.policyName.placeholder": "Add a unique policy name",
"admin.access_control.policy.edit_policy.switch_to_advanced": "Switch to Advanced Mode",
"admin.access_control.policy.edit_policy.switch_to_simple": "Switch to Simple Mode",
"admin.access_control.policy.edit_policy.title": "Edit Access Control Policy",
"admin.access_control.policy.edit_policy.title": "Edit Membership Policy",
"admin.access_control.policy.enforce_immediately": "Enforce policy immediately",
"admin.access_control.policy.save_only": "Are you sure you want to save this access control policy?",
"admin.access_control.policy.save_policy_confirmation_body": "Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.",
"admin.access_control.policy.save_policy_confirmation_body.inactive": "Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this access policy.",
"admin.access_control.policy.save_only": "Are you sure you want to save this membership policy?",
"admin.access_control.policy.save_policy_confirmation_body": "Applying this policy will allow users with the appropriate attribute values to be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this membership policy.",
"admin.access_control.policy.save_policy_confirmation_body.inactive": "Only users who match the attribute values configured below can be added to the selected channels. Existing channel members will be removed from these channels if they are not assigned the values defined in this membership policy.",
"admin.access_control.policy.save_policy_confirmation_body.mixed": "This policy is applied to channels of mixed types. For private channels, matching users will be granted access and non-matching members will be removed. For public channels, matching users will see these channels as recommendations and will be auto-added when auto-add is enabled; no existing members will be removed.",
"admin.access_control.policy.save_policy_confirmation_body.mixed_inactive": "This policy is applied to channels of mixed types. For private channels, only matching users can be added and non-matching existing members will be removed. For public channels, the policy acts as a recommendation only; no existing members will be removed.",
"admin.access_control.policy.save_policy_confirmation_body.public": "Matching users will see these public channels as recommendations and, when auto-add is enabled, will be added automatically. Anyone can still join these channels; no existing members will be removed.",
"admin.access_control.policy.save_policy_confirmation_body.public_inactive": "Matching users will see these public channels as recommendations only; no existing members will be removed. Turn on Active (auto-add) to add matching users automatically.",
"admin.access_control.policy.save_policy_confirmation_subheader": "{count} channels will be affected.",
"admin.access_control.policy.save_policy_confirmation_title": "Save access control policy ",
"admin.access_control.policy.save_policy_confirmation_title": "Save membership policy",
"admin.access_control.sync_jobs.create_job": "Create Job",
"admin.access_control.sync_jobs.description": "Apply membership policies to their assigned resources.",
"admin.access_control.sync_jobs.run": "Run Sync Job",
"admin.access_control.sync_jobs.running": "Running Job...",
"admin.access_control.sync_jobs.title": "Membership Sync Jobs",
"admin.access_control.table_editor.add_attribute": "Add attribute",
"admin.access_control.table_editor.attribute": "Attribute",
"admin.access_control.table_editor.attribute_spaces_not_supported": "CEL is not compatible with variable names containing spaces",
@ -562,9 +571,9 @@
"admin.channel_list.private": "Private",
"admin.channel_list.public": "Public",
"admin.channel_settings.channel_detail.access_control_policy_actions": "Actions",
"admin.channel_settings.channel_detail.access_control_policy_description": "Select an access policy for this channel to restrict membership.",
"admin.channel_settings.channel_detail.access_control_policy_description": "Select a membership policy for this channel.",
"admin.channel_settings.channel_detail.access_control_policy_name": "Name",
"admin.channel_settings.channel_detail.access_control_policy_title": "Access policy",
"admin.channel_settings.channel_detail.access_control_policy_title": "Membership policy",
"admin.channel_settings.channel_detail.archive_confirm.button": "Save and Archive Channel",
"admin.channel_settings.channel_detail.archive_confirm.message": "Saving will archive the channel from the team and make it's contents inaccessible for all users. Are you sure you wish to save and archive this channel?",
"admin.channel_settings.channel_detail.archive_confirm.title": "Save and Archive Channel",
@ -578,24 +587,24 @@
"admin.channel_settings.channel_detail.manageTitle": "Channel Management",
"admin.channel_settings.channel_detail.membersDescription": "A list of users who are currently in the channel right now",
"admin.channel_settings.channel_detail.membersTitle": "Members",
"admin.channel_settings.channel_detail.policy_following": "This channel is currently using the following access policy.",
"admin.channel_settings.channel_detail.policy_following": "This channel is currently using the following membership policy.",
"admin.channel_settings.channel_detail.profileDescription": "Summary of the channel, including the channel name.",
"admin.channel_settings.channel_detail.profileTitle": "Channel Profile",
"admin.channel_settings.channel_detail.remove_policy": "Remove all",
"admin.channel_settings.channel_detail.remove_policy.aria_label": "Remove policy",
"admin.channel_settings.channel_detail.select_policy": "Apply an access policy for this channel to restrict membership",
"admin.channel_settings.channel_detail.select_policy_description": "An access control policy will restrict channel membership based on user attributes.",
"admin.channel_settings.channel_detail.select_policy_title": "Select an Access Control Policy",
"admin.channel_settings.channel_detail.select_policy_description": "A membership policy defines who should be in this channel based on user attributes.",
"admin.channel_settings.channel_detail.select_policy_title": "Select a Membership Policy",
"admin.channel_settings.channel_detail.syncedGroupsDescription": "Add and remove channel members based on their group membership.",
"admin.channel_settings.channel_detail.syncedGroupsTitle": "Synced Groups",
"admin.channel_settings.channel_details.add_group": "Add Group",
"admin.channel_settings.channel_details.archiveChannel": "Archive Channel",
"admin.channel_settings.channel_details.attribute_based_description": "Restrict which users can be invited to this channel based on their user attributes and values. Only people who match the specified conditions will be allowed to be selected and added to this channel.",
"admin.channel_settings.channel_details.attribute_based_description_public": "Recommend this channel to users whose attributes match the rules and (optionally) auto-add them. Anyone can still join freely.",
"admin.channel_settings.channel_details.default_channel_not_supported": "The default channel cannot have a membership policy.",
"admin.channel_settings.channel_details.isDefaultDescr": "This default channel cannot be converted into a private channel.",
"admin.channel_settings.channel_details.isPublic": "Public channel or private channel",
"admin.channel_settings.channel_details.isPublicDescr": "Select Public for a channel any user can find and join. {br}Select Private to require channel invitations to join. {br}Use this switch to change this channel from public to private or from private to public.",
"admin.channel_settings.channel_details.policy_enforced_title": "Enable attribute based channel access",
"admin.channel_settings.channel_details.private_channel_only": "Only private channels can be attribute based.",
"admin.channel_settings.channel_details.syncGroupMembers": "Sync Group Members",
"admin.channel_settings.channel_details.syncGroupMembersDescr": "When enabled, adding and removing users from groups will add or remove them from this channel. The only way of inviting members to this channel is by adding the groups they belong to. <link>Learn More</link>",
"admin.channel_settings.channel_details.unarchiveChannel": "Unarchive Channel",
@ -3969,6 +3978,10 @@
"channel_invite.no_options_message": "No matches found - <InvitationModalLink>Invite them to the team</InvitationModalLink>",
"channel_invite.policy_enforced.description": "Only people who match the specified access rules can be selected and added to this channel.",
"channel_invite.policy_enforced.title": "Channel access is restricted by user attributes",
"channel_invite.policy_recommended.description": "A membership policy suggests who should be members of this channel. You can still add anyone who can join a public channel.",
"channel_invite.policy_recommended.title": "This channel has recommended members based on user attributes",
"channel_invite.recommended_tag": "Recommended",
"channel_invite.recommended_tag.tooltip": "Matches the suggested membership for this channel",
"channel_loader.posted": " posted a message",
"channel_loader.postedImage": " posted an image",
"channel_loader.socketError": "Please check connection, Mattermost unreachable. If issue persists, ask administrator to [check WebSocket port](!https://docs.mattermost.com/install/troubleshooting.html#please-check-connection-mattermost-unreachable-if-issue-persists-ask-administrator-to-check-websocket-port).",
@ -4004,6 +4017,7 @@
"channel_members_rhs.member.select_role_guest": "Guest",
"channel_members_rhs.member.send_message": "Send message",
"channel_members_rhs.policy_enforced_restrictions": "Channel access is restricted by user attributes",
"channel_members_rhs.policy_recommended_description": "This channel has recommended members based on user attributes",
"channel_members_rhs.search_bar.aria.cancel_search_button": "cancel members search",
"channel_members_rhs.search_bar.placeholder": "Search members",
"channel_mention_badge.urgent_tooltip": "You have an urgent mention",
@ -4060,9 +4074,11 @@
"channel_settings_modal.name.placeholder": "Enter a name for your channel",
"channel_settings_modal.purpose.placeholder": "Enter a purpose for this channel (optional)",
"channel_settings.access_rules.auto_sync": "Auto-add members based on access rules",
"channel_settings.access_rules.auto_sync_disabled_description": "Access rules will prevent unauthorized users from joining, but will not automatically add qualifying members.",
"channel_settings.access_rules.auto_sync_disabled_description": "Access rules will prevent users who do not match from being added, but qualifying users will not be added automatically.",
"channel_settings.access_rules.auto_sync_disabled_empty_state": "Auto-add is disabled because no access rules are defined",
"channel_settings.access_rules.auto_sync_enabled_description": "Users who match the configured attribute values will be automatically added as members and those who no longer match will be removed.",
"channel_settings.access_rules.auto_sync_disabled_public_description": "This channel will appear under \"Recommended channels\" for users who match the rules. Anyone can still join freely.",
"channel_settings.access_rules.auto_sync_enabled_description": "Qualifying users are automatically added as members, and members who no longer match will be removed.",
"channel_settings.access_rules.auto_sync_enabled_public_description": "Qualifying users are automatically added as members. Members can still leave on their own — no one is removed based on these rules.",
"channel_settings.access_rules.auto_sync_requires_expression": "Define access rules to enable auto-add members",
"channel_settings.access_rules.confirm_modal.allowed_tab": "Allowed ({count})",
"channel_settings.access_rules.confirm_modal.cancel": "Cancel",
@ -4088,7 +4104,9 @@
"channel_settings.access_rules.parse_error": "Invalid expression format",
"channel_settings.access_rules.save_error": "Failed to save access rules",
"channel_settings.access_rules.subtitle": "Select user attributes and values as rules to restrict channel membership",
"channel_settings.access_rules.subtitle_public": "Select user attributes and values to describe who should be in this channel. Rules are advisory: anyone can still join.",
"channel_settings.access_rules.title": "Access Rules",
"channel_settings.access_rules.title_public": "Membership Rules",
"channel_settings.activity_warning.acknowledge_expose_history": "I acknowledge this change will expose all historical channel messages to more users",
"channel_settings.activity_warning.exposing_history_description": "Modifying access rules may allow new users to view the entire message history, including messages sent before this change.",
"channel_settings.activity_warning.exposing_history_title": "Exposing channel history",
@ -4104,6 +4122,7 @@
"channel_settings.modal.archiveTitle": "Archive channel?",
"channel_settings.modal.confirmArchive": "Confirm",
"channel_settings.modal.title": "Channel Settings",
"channel_settings.policy_enforced.cannot_change_channel_type": "This channel has a membership policy applied. Remove the policy before changing between public and private.",
"channel_settings.purpose.description": "Describe how this channel should be used.",
"channel_settings.purpose.header": "This is the text that will appear in the header of the channel beside the channel name. You can use markdown to include links by typing [Link Title](http://example.com).",
"channel_settings.purpose.label": "Channel Purpose",
@ -4126,10 +4145,10 @@
"channel_settings.share_channel_with_workspaces.workspaces_label": "Workspaces this channel is shared with",
"channel_settings.share_channel_with_workspaces.workspaces_label_empty": "This channel is not shared with any connected workspaces yet.",
"channel_settings.sharing_errors": "There has been errors while sharing the channel with some workspaces. Please try again.",
"channel_settings.tab.access_control": "Access Control",
"channel_settings.tab.archive": "Archive Channel",
"channel_settings.tab.configuration": "Configuration",
"channel_settings.tab.info": "Info",
"channel_settings.tab.membership_policy": "Membership Policy",
"channel_settings.unknown_error": "Something went wrong.",
"channel_switch_modal.deactivated": "Deactivated",
"channel_switch_modal.has_draft": "Has draft",
@ -4744,7 +4763,7 @@
"general_tab.teamNameRestrictions": "Team Name must be {min} or more characters up to a maximum of {max}. You can add a longer team description.",
"generic_btn.cancel": "Cancel",
"generic_btn.save": "Save",
"generic_icons.access_rules": "Access Rules Icon",
"generic_icons.access_rules": "Membership Policy Icon",
"generic_icons.add": "Add Icon",
"generic_icons.add-mail": "Add Mail Icon",
"generic_icons.add-reaction": "Add Reaction Icon",
@ -5422,12 +5441,14 @@
"more_channels.noMore": "No results for \"{text}\"",
"more_channels.noPrivate": "No private channels",
"more_channels.noPublic": "No public channels",
"more_channels.noRecommended": "No recommended channels",
"more_channels.prev": "Previous",
"more_channels.searchError": "Try searching different keywords, checking for typos or adjusting the filters.",
"more_channels.show_all_channels": "Channel Type: All",
"more_channels.show_archived_channels": "Channel Type: Archived",
"more_channels.show_private_channels": "Channel Type: Private",
"more_channels.show_public_channels": "Channel Type: Public",
"more_channels.show_recommended_channels": "Recommended channels",
"more_channels.title": "Browse Channels",
"more_channels.view": "View",
"more_direct_channels.directchannel.deactivated": "{displayname} - Deactivated",
@ -6422,6 +6443,7 @@
"suggestion.private_channel": "Private channel",
"suggestion.public": "Public channels",
"suggestion.public_channel": "Public channel",
"suggestion.recommended": "Recommended channels",
"suggestion.search.direct": "Direct Messages",
"suggestion.search.group": "Group Mentions",
"suggestion.search.private": "Private Channels",
@ -6436,9 +6458,15 @@
"system_notice.dont_show": "Don't Show Again",
"system_notice.remind_me": "Remind Me Later",
"system_policy_indicator.base_message": "This {resourceType} has system-level access {policyText} applied",
"system_policy_indicator.description_with_policies": "This {resourceType} has system-level access policies applied: {policyList}. Any custom access rules you set here will be applied in addition to this policy.",
"system_policy_indicator.base_message_membership": "This {resourceType} has system-level membership {policyText} applied",
"system_policy_indicator.description_with_membership_policies": "This {resourceType} has system-level membership policies applied{policySuffix}. Any custom membership rules you set here will be applied in addition to these policies.",
"system_policy_indicator.description_with_membership_policy": "This {resourceType} has a system-level membership policy applied{policySuffix}. Any custom membership rules you set here will be applied in addition to this policy.",
"system_policy_indicator.description_with_policies": "This {resourceType} has system-level access policies applied{policySuffix}. Any custom access rules you set here will be applied in addition to these policies.",
"system_policy_indicator.description_with_policy": "This {resourceType} has a system-level access policy applied{policySuffix}. Any custom access rules you set here will be applied in addition to this policy.",
"system_policy_indicator.more_policies": "{count} more",
"system_policy_indicator.multiple_membership_policies_title": "Multiple system membership policies applied to this {resourceType}",
"system_policy_indicator.multiple_policies_title": "Multiple system access policies applied to this {resourceType}",
"system_policy_indicator.single_membership_policy_title": "System membership policy applied to this {resourceType}",
"system_policy_indicator.single_policy_title": "System access policy applied to this {resourceType}",
"tag.default.agent": "AGENT",
"tag.default.beta": "BETA",
@ -6476,6 +6504,8 @@
"team_settings.policy_editor.channel_selector.subtitle": "Add channels that this membership policy will apply to.",
"team_settings.policy_editor.confirmation.apply": "Apply policy",
"team_settings.policy_editor.confirmation.body": "This policy will grant users with matching attribute values access to <b>{count} assigned {count, plural, one {channel} other {channels}}</b> and remove users without these attribute values.",
"team_settings.policy_editor.confirmation.body_mixed": "This policy is applied to <b>{count} assigned {count, plural, one {channel} other {channels}}</b> of mixed types. For <b>{privateCount, plural, one {# private channel} other {# private channels}}</b>, matching users will be granted access and non-matching members will be removed. For <b>{publicCount, plural, one {# public channel} other {# public channels}}</b>, the policy is advisory: matching users will be recommended to join the channel and auto-added when enabled, but no existing members will ever be removed.",
"team_settings.policy_editor.confirmation.body_public": "This policy will be applied to <b>{count} assigned {count, plural, one {public channel} other {public channels}}</b>. Matching users will see {count, plural, one {this channel} other {these channels}} as recommendations, and will be auto-added when auto-add is enabled. No existing members will be removed.",
"team_settings.policy_editor.confirmation.cancel": "Cancel",
"team_settings.policy_editor.confirmation.question": "Are you sure you want to save and apply the membership policy?",
"team_settings.policy_editor.confirmation.title": "Save and apply policy",
@ -6487,7 +6517,7 @@
"team_settings.policy_editor.error.incomplete_rule": "Please complete all attribute rules with a value",
"team_settings.policy_editor.error.no_channels_delete_hint": "Remove all channels to delete, or undo to keep the policy.",
"team_settings.policy_editor.error.self_exclusion": "You cannot save these rules because they would remove your access to this policy. Adjust the rules to include your user attributes.",
"team_settings.policy_editor.error.validation_failed": "Failed to validate access rules. Please try again.",
"team_settings.policy_editor.error.validation_failed": "Failed to validate membership rules. Please try again.",
"team_settings.policy_editor.name_hint": "Give your policy a name that will be used to identify it in the policies list.",
"team_settings.policy_editor.name_label": "Membership policy name",
"team_settings.policy_editor.name_placeholder": "Add a unique policy name",
@ -6495,8 +6525,8 @@
"team_settings.policy_editor.policy_deleted": "Policy deleted",
"team_settings.policy_editor.policy_saved": "Policy saved",
"team_settings.policy_editor.policy_updated": "Policy updated",
"team_settings.policy_editor.rules_subtitle": "Select user attributes and values as rules to restrict access",
"team_settings.policy_editor.rules_title": "Access rules",
"team_settings.policy_editor.rules_subtitle": "Select user attributes and values as rules for membership",
"team_settings.policy_editor.rules_title": "Membership rules",
"team_settings.policy_editor.undo": "Undo",
"team_settings.sync_status.days_ago": "Last synced {count} {count, plural, one {day} other {days}} ago.",
"team_settings.sync_status.hours_ago": "Last synced {count} {count, plural, one {hour} other {hours}} ago.",

View file

@ -805,6 +805,26 @@ export function getArchivedChannels(teamId: string, page = 0, perPage: number =
};
}
export function getRecommendedChannelsForUser(teamId: string): ActionFuncAsync<Channel[]> {
return async (dispatch, getState) => {
let channels;
try {
channels = await Client4.getRecommendedChannelsForUser(teamId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
return {error};
}
dispatch({
type: ChannelTypes.RECEIVED_CHANNELS,
teamId,
data: channels,
});
return {data: channels};
};
}
export function getAllChannelsWithCount(page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE, notAssociatedToGroup = '', excludeDefaultChannels = false, includeDeleted = false, excludePolicyConstrained = false, accessControlPolicyEnforced = false, excludeAccessControlPolicyEnforced = false): ActionFuncAsync<ChannelsWithTotalCount> {
return async (dispatch, getState) => {
dispatch({type: ChannelTypes.GET_ALL_CHANNELS_REQUEST, data: null});

View file

@ -979,6 +979,30 @@ export default class Client4 {
);
};
// getProfilesMatchingChannelPolicy returns only users who satisfy the ABAC
// policy on the channel and are not yet members. Useful for public
// policy-enforced channels to annotate recommended members without
// filtering the primary invite list.
getProfilesMatchingChannelPolicy = (teamId: string, channelId: string, groupConstrained: boolean, perPage = PER_PAGE_DEFAULT, cursorId = '') => {
const queryStringObj: any = {
in_team: teamId,
not_in_channel: channelId,
per_page: perPage,
abac_match_only: true,
};
if (cursorId) {
queryStringObj.cursor_id = cursorId;
}
if (groupConstrained) {
queryStringObj.group_constrained = true;
}
return this.doFetch<UserProfile[]>(
`${this.getUsersRoute()}${buildQueryString(queryStringObj)}`,
{method: 'get'},
);
};
getProfilesInGroup = (groupId: string, page = 0, perPage = PER_PAGE_DEFAULT, sort = '') => {
return this.doFetch<UserProfile[]>(
`${this.getUsersRoute()}${buildQueryString({in_group: groupId, page, per_page: perPage, sort})}`,
@ -1846,6 +1870,13 @@ export default class Client4 {
);
};
getRecommendedChannelsForUser = (teamId: string) => {
return this.doFetch<ServerChannel[]>(
`${this.getTeamRoute(teamId)}/channels/recommended`,
{method: 'get'},
);
};
getMyChannels = (teamId: string, includeDeleted = false) => {
return this.doFetch<ServerChannel[]>(
`${this.getUserRoute('me')}/teams/${teamId}/channels${buildQueryString({include_deleted: includeDeleted})}`,