From b3bc4b6f1bfda48f8186cb68224c31829a26cf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Mon, 23 Jun 2025 18:17:55 +0200 Subject: [PATCH] Mm 64299 disable guest invite in abac channels (#31139) * MM-64299 - disable guest invite in abac channels * filter the abac channel list for guest * add filter in the back-end too * add proper translation * simplify the condition for enforced channels and add the unit tests * enhance validation for not inviting guest users when abac enforced channel * add missing translation * add value to empty translation * prevent showing the channel name if abac protected --- server/channels/app/team.go | 6 + server/channels/app/team_test.go | 46 +++++ server/i18n/en.json | 4 + .../channel_invite_modal.test.tsx | 27 +++ .../channel_invite_modal.tsx | 10 +- .../__snapshots__/invite_as.test.tsx.snap | 1 + .../invitation_modal/add_to_channels.tsx | 4 +- .../src/components/invitation_modal/index.tsx | 5 +- .../invitation_modal.test.tsx | 75 ++++++++ .../invitation_modal/invitation_modal.tsx | 36 +++- .../invitation_modal/invite_as.test.tsx | 164 ++++++++++++++++++ .../components/invitation_modal/invite_as.tsx | 3 +- .../invitation_modal/invite_view.tsx | 1 + 13 files changed, 369 insertions(+), 13 deletions(-) diff --git a/server/channels/app/team.go b/server/channels/app/team.go index 8f8466db60..1d6c13a6d3 100644 --- a/server/channels/app/team.go +++ b/server/channels/app/team.go @@ -1499,7 +1499,13 @@ func (a *App) prepareInviteGuestsToChannels(teamID string, guestsInvite *model.G if channel.TeamId != teamID { return nil, nil, nil, model.NewAppError("prepareInviteGuestsToChannels", "api.team.invite_guests.channel_in_invalid_team.app_error", nil, "", http.StatusBadRequest) } + + // Check if the channel has access control policy enforcement + if channel.PolicyEnforced { + return nil, nil, nil, model.NewAppError("prepareInviteGuestsToChannels", "api.team.invite_guests.policy_enforced_channel.app_error", nil, "", http.StatusBadRequest) + } } + return user, team, channels, nil } diff --git a/server/channels/app/team_test.go b/server/channels/app/team_test.go index 61ddf79899..47f8ee5594 100644 --- a/server/channels/app/team_test.go +++ b/server/channels/app/team_test.go @@ -1707,6 +1707,52 @@ func TestInviteGuestsToChannelsGracefully(t *testing.T) { }) } +func TestInviteGuestsToChannelsWithPolicyEnforced(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableEmailInvitations = true + }) + + // Create a private channel + channel := th.CreatePrivateChannel(th.Context, th.BasicTeam) + + // Create a policy with the same ID as the channel + channelPolicy := &model.AccessControlPolicy{ + Type: model.AccessControlPolicyTypeChannel, + ID: channel.Id, // Use the channel ID directly + Name: "Test Channel Policy", + Revision: 1, + Version: model.AccessControlPolicyVersionV0_1, + Rules: []model.AccessControlPolicyRule{ + { + Actions: []string{"view", "join_channel"}, + Expression: "user.attributes.program == \"test-program\"", + }, + }, + } + + // Save the channel policy + channelPolicy, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, channelPolicy) + require.NoError(t, err) + require.NotNil(t, channelPolicy) + + // Attempt to invite guests to the policy-enforced channel + guestsInvite := &model.GuestsInvite{ + Emails: []string{"guest@example.com"}, + Channels: []string{channel.Id}, + Message: "test message", + } + + // Call the function we want to test + _, _, _, appErr := th.App.prepareInviteGuestsToChannels(th.BasicTeam.Id, guestsInvite, th.BasicUser.Id) + + // Verify that the appropriate error is returned + require.NotNil(t, appErr) + require.Equal(t, "api.team.invite_guests.policy_enforced_channel.app_error", appErr.Id) +} + func TestTeamSendEvents(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic() diff --git a/server/i18n/en.json b/server/i18n/en.json index 34149e4bfb..0b3181088d 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -3230,6 +3230,10 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "The channels of the invite must be part of the team of the invite." }, + { + "id": "api.team.invite_guests.policy_enforced_channel.app_error", + "translation": "Cannot invite guests users to this channel because it has access restrictions based on user attributes." + }, { "id": "api.team.invite_guests_to_channels.disabled.error", "translation": "Guest accounts are disabled" diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx index 5965814c3f..497093dc35 100644 --- a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx +++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.test.tsx @@ -690,4 +690,31 @@ describe('components/channel_invite_modal', () => { // Should not call getProfilesNotInChannel after clearing the search expect(getProfilesNotInChannelMock).not.toHaveBeenCalled(); }); + + test('should hide the invite as guest link when channel has policy_enforced', () => { + const channelWithPolicy = { + ...channel, + policy_enforced: true, + }; + + const props = { + ...baseProps, + channel: channelWithPolicy, + canInviteGuests: true, + emailInvitationsEnabled: true, + }; + + const wrapper = shallowWithIntl( + , + ); + + // Check that the invite as guest link is not shown + const invitationLinks = wrapper.find('InviteModalLink'); + + // There should be no InviteModalLink with inviteAsGuest=true + const guestInviteLinks = invitationLinks.findWhere( + (node) => node.prop('inviteAsGuest') === true, + ); + expect(guestInviteLinks).toHaveLength(0); + }); }); diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx index 96b205a677..68958f6530 100644 --- a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx +++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.tsx @@ -506,7 +506,7 @@ const ChannelInviteModalComponent = (props: Props) => { props.actions.closeModal(ModalIdentifiers.CHANNEL_INVITE); }; - const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string}) => { + const InviteModalLink = (props: {inviteAsGuest?: boolean; children: React.ReactNode; id?: string; abacChannelPolicyEnforced?: boolean}) => { return ( { initialValue: term, inviteAsGuest: props.inviteAsGuest, focusOriginElement: 'customNoOptionsMessageLink', + canInviteGuests: Boolean(!props.abacChannelPolicyEnforced), }} onClick={closeMembersInviteModal} id={props.id} @@ -535,7 +536,10 @@ const ChannelInviteModalComponent = (props: Props) => { defaultMessage='No matches found - Invite them to the team' values={{ InvitationModalLink: (chunks: string) => ( - + {chunks} ), @@ -647,7 +651,7 @@ const ChannelInviteModalComponent = (props: Props) => { teamId={channel.team_id} users={usersNotInTeam} /> - {(props.emailInvitationsEnabled && props.canInviteGuests) && inviteGuestLink} + {(props.emailInvitationsEnabled && props.canInviteGuests && !channel.policy_enforced) && inviteGuestLink} diff --git a/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap b/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap index 13394145be..692525e9f1 100644 --- a/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap +++ b/webapp/channels/src/components/invitation_modal/__snapshots__/invite_as.test.tsx.snap @@ -33,6 +33,7 @@ exports[`components/cloud_start_trial_btn/cloud_start_trial_btn should match sna } > { type OwnProps = { channelToInvite?: Channel; + canInviteGuests?: boolean; } export function mapStateToProps(state: GlobalState, props: OwnProps) { @@ -72,7 +73,9 @@ export function mapStateToProps(state: GlobalState, props: OwnProps) { const emailInvitationsEnabled = config.EnableEmailInvitations === 'true'; const isEnterpriseReady = config.BuildEnterpriseReady === 'true'; const isGroupConstrained = Boolean(currentTeam?.group_constrained); - const canInviteGuests = !isGroupConstrained && isEnterpriseReady && guestAccountsEnabled && haveICurrentTeamPermission(state, Permissions.INVITE_GUEST); + const calculatedCanInviteGuests = !isGroupConstrained && isEnterpriseReady && guestAccountsEnabled && haveICurrentTeamPermission(state, Permissions.INVITE_GUEST); + const canInviteGuests = props.canInviteGuests === undefined ? calculatedCanInviteGuests : (calculatedCanInviteGuests && props.canInviteGuests); + const isCloud = license.Cloud === 'true'; const canAddUsers = haveICurrentTeamPermission(state, Permissions.ADD_USER_TO_TEAM); diff --git a/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx b/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx index 5529d042cb..f1e7cc23af 100644 --- a/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx +++ b/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx @@ -150,4 +150,79 @@ describe('InvitationModal', () => { expect(wrapper.find(NoPermissionsView).length).toBe(1); }); + + it('filters out policy_enforced channels when inviting guests', async () => { + // Create test channels with and without policy_enforced flag + const regularChannel = TestHelper.getChannelMock({ + id: 'regular-channel', + display_name: 'Regular Channel', + name: 'regular-channel', + policy_enforced: false, + }); + + const policyEnforcedChannel = TestHelper.getChannelMock({ + id: 'policy-enforced-channel', + display_name: 'Policy Enforced Channel', + name: 'policy-enforced-channel', + policy_enforced: true, + }); + + props = { + ...props, + invitableChannels: [regularChannel, policyEnforcedChannel], + }; + + const wrapper = mountWithThemedIntl( + + + , + ); + + // Get the component instance with proper typing + const instance = wrapper.find(InvitationModal).instance() as InvitationModal; + + // Set invite type to GUEST + instance.setState({ + invite: { + ...instance.state.invite, + inviteType: 'GUEST', + }, + }); + + // Call channelsLoader with empty search term + const guestChannels = await instance.channelsLoader(''); + + // Verify only non-policy-enforced channels are returned for guests + expect(guestChannels.length).toBe(1); + expect(guestChannels[0].id).toBe('regular-channel'); + + // Set invite type to MEMBER + instance.setState({ + invite: { + ...instance.state.invite, + inviteType: 'MEMBER', + }, + }); + + // Call channelsLoader with empty search term + const memberChannels = await instance.channelsLoader(''); + + // Verify all channels are returned for members + expect(memberChannels.length).toBe(2); + + // Test with search term + instance.setState({ + invite: { + ...instance.state.invite, + inviteType: 'GUEST', + }, + }); + + // Call channelsLoader with search term that matches both channels + const guestChannelsWithSearch = await instance.channelsLoader('channel'); + + // Verify only non-policy-enforced channels are returned for guests + expect(guestChannelsWithSearch.length).toBe(1); + expect(guestChannelsWithSearch[0].id).toBe('regular-channel'); + }); }); diff --git a/webapp/channels/src/components/invitation_modal/invitation_modal.tsx b/webapp/channels/src/components/invitation_modal/invitation_modal.tsx index e877b9d957..299b3f113a 100644 --- a/webapp/channels/src/components/invitation_modal/invitation_modal.tsx +++ b/webapp/channels/src/components/invitation_modal/invitation_modal.tsx @@ -262,15 +262,39 @@ export default class InvitationModal extends React.PureComponent { debouncedSearchChannels = debounce((term) => this.props.currentTeam && this.props.actions.searchChannels(this.props.currentTeam.id, term), 150); + // Filter channels based on the current invite type and search term + filterChannels = (channels: Channel[], isGuestInvite: boolean, searchTerm: string = '') => { + return channels.filter((channel) => { + // For guest invites, filter out policy_enforced channels + if (isGuestInvite && channel.policy_enforced) { + return false; + } + + // If there's a search term, filter by name match + if (searchTerm) { + const lowerSearchTerm = searchTerm.toLowerCase(); + return channel.display_name.toLowerCase().includes(lowerSearchTerm) || + channel.name.toLowerCase().includes(lowerSearchTerm); + } + + return true; + }); + }; + channelsLoader = async (value: string) => { - if (!value) { - return this.props.invitableChannels; + const isGuestInvite = this.state.invite.inviteType === InviteType.GUEST; + + // If there's a search term, search the channels from the server + if (value) { + this.debouncedSearchChannels(value); } - this.debouncedSearchChannels(value); - return this.props.invitableChannels.filter((channel) => { - return channel.display_name.toLowerCase().startsWith(value.toLowerCase()) || channel.name.toLowerCase().startsWith(value.toLowerCase()); - }); + // Apply filtering to the channels + return this.filterChannels( + this.props.invitableChannels, + isGuestInvite, + value, + ); }; onChannelsChange = (channels: Channel[]) => { diff --git a/webapp/channels/src/components/invitation_modal/invite_as.test.tsx b/webapp/channels/src/components/invitation_modal/invite_as.test.tsx index 2d65112fd6..5d94a6aa1a 100644 --- a/webapp/channels/src/components/invitation_modal/invite_as.test.tsx +++ b/webapp/channels/src/components/invitation_modal/invite_as.test.tsx @@ -27,6 +27,7 @@ describe('components/cloud_start_trial_btn/cloud_start_trial_btn', () => { setInviteAs: jest.fn(), inviteType: InviteType.MEMBER, titleClass: 'title', + canInviteGuests: true, }; const state = { @@ -349,4 +350,167 @@ describe('components/cloud_start_trial_btn/cloud_start_trial_btn', () => { const badgeText = wrapper.find('.Tag span.tag-text').text(); expect(badgeText).toBe('Professional feature'); }); + + test('guest radio-button is disabled when canInviteGuests prop is false', () => { + const propsWithCanInviteGuestsFalse = { + ...props, + canInviteGuests: false, + }; + + // Use a state where normally guests would be allowed (paid subscription) + const paidState = { + entities: { + admin: { + prevTrialLicense: { + IsLicensed: 'true', + }, + }, + general: { + config: { + BuildEnterpriseReady: 'true', + }, + license: { + IsLicensed: 'true', + Cloud: 'true', + SkuShortName: 'professional', + }, + }, + cloud: { + subscription: { + is_free_trial: 'false', + trial_end_at: 0, + sku: 'professional', + product_id: 'cloud-professional-id', + }, + products: { + 'cloud-professional-id': { + sku: 'professional', + }, + }, + }, + users: { + currentUserId: 'uid', + profiles: { + uid: {roles: 'system_admin'}, + }, + }, + }, + }; + const store = mockStore(paidState); + const wrapper = mountWithIntl( + + + , + ); + + const guestRadioButton = wrapper.find('input[value="GUEST"]'); + expect(guestRadioButton.props().disabled).toBe(true); + }); + + test('guest radio-button is enabled when canInviteGuests prop is true and other conditions allow it', () => { + const propsWithCanInviteGuestsTrue = { + ...props, + canInviteGuests: true, + }; + + // Use a state where guests would be allowed (paid subscription) + const paidState = { + entities: { + admin: { + prevTrialLicense: { + IsLicensed: 'true', + }, + }, + general: { + config: { + BuildEnterpriseReady: 'true', + }, + license: { + IsLicensed: 'true', + Cloud: 'true', + SkuShortName: 'professional', + }, + }, + cloud: { + subscription: { + is_free_trial: 'false', + trial_end_at: 0, + sku: 'professional', + product_id: 'cloud-professional-id', + }, + products: { + 'cloud-professional-id': { + sku: 'professional', + }, + }, + }, + users: { + currentUserId: 'uid', + profiles: { + uid: {roles: 'system_admin'}, + }, + }, + }, + }; + const store = mockStore(paidState); + const wrapper = mountWithIntl( + + + , + ); + + const guestRadioButton = wrapper.find('input[value="GUEST"]'); + expect(guestRadioButton.props().disabled).toBe(false); + }); + + test('guest radio-button is disabled when canInviteGuests prop is undefined and defaults to system behavior', () => { + // Test with starter plan where guests should be disabled by default + const state = { + entities: { + admin: { + prevTrialLicense: { + IsLicensed: 'false', + }, + }, + general: { + config: { + BuildEnterpriseReady: 'true', + }, + license: { + IsLicensed: 'true', + Cloud: 'true', + SkuShortName: CloudProducts.STARTER, + }, + }, + cloud: { + subscription: { + is_free_trial: 'false', + trial_end_at: 0, + sku: CloudProducts.STARTER, + product_id: 'cloud-starter-id', + }, + products: { + 'cloud-starter-id': { + sku: CloudProducts.STARTER, + }, + }, + }, + users: { + currentUserId: 'uid', + profiles: { + uid: {roles: 'system_admin'}, + }, + }, + }, + }; + const store = mockStore(state); + const wrapper = mountWithIntl( + + + , + ); + + const guestRadioButton = wrapper.find('input[value="GUEST"]'); + expect(guestRadioButton.props().disabled).toBe(true); + }); }); diff --git a/webapp/channels/src/components/invitation_modal/invite_as.tsx b/webapp/channels/src/components/invitation_modal/invite_as.tsx index 1af0f59e87..04ba324c3c 100644 --- a/webapp/channels/src/components/invitation_modal/invite_as.tsx +++ b/webapp/channels/src/components/invitation_modal/invite_as.tsx @@ -34,6 +34,7 @@ export type Props = { setInviteAs: (inviteType: InviteType) => void; inviteType: InviteType; titleClass?: string; + canInviteGuests?: boolean; } export default function InviteAs(props: Props) { @@ -138,7 +139,7 @@ export default function InviteAs(props: Props) { } // disable the radio button logic (is disabled when is starter - pre and post trial) - if (isStarter) { + if (isStarter || !props.canInviteGuests) { guestDisabled = (id: string) => { return (id === InviteType.GUEST); }; diff --git a/webapp/channels/src/components/invitation_modal/invite_view.tsx b/webapp/channels/src/components/invitation_modal/invite_view.tsx index b43240e4c5..0c5046360f 100644 --- a/webapp/channels/src/components/invitation_modal/invite_view.tsx +++ b/webapp/channels/src/components/invitation_modal/invite_view.tsx @@ -240,6 +240,7 @@ export default function InviteView(props: Props) { inviteType={props.inviteType} setInviteAs={props.setInviteAs} titleClass='InviteView__sectionTitle' + canInviteGuests={props.canInviteGuests} /> } {(props.inviteType === InviteType.GUEST || (props.inviteType === InviteType.MEMBER && props.channelToInvite)) && (