mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
[MM-68497] Enables membership policies on public channels with advisory semantics (#36275)
This commit is contained in:
parent
6c0e0fee4a
commit
4da11e81af
57 changed files with 3500 additions and 465 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"]');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "/")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
/>
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@
|
|||
right: 20px;
|
||||
}
|
||||
|
||||
&__recommended-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&__policy-banner {
|
||||
.TagGroup {
|
||||
margin-top: 12px;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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})}`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue