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
Этот коммит содержится в:
Pablo Vélez
2025-06-23 18:17:55 +02:00
коммит произвёл GitHub
родитель 82c1de2b4b
Коммит b3bc4b6f1b
13 изменённых файлов: 369 добавлений и 13 удалений

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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()

Просмотреть файл

@@ -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"

Просмотреть файл

@@ -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(
<ChannelInviteModal {...props}/>,
);
// 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);
});
});

Просмотреть файл

@@ -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 (
<ToggleModalButton
className={`${props.inviteAsGuest ? 'invite-as-guest' : ''} btn btn-link`}
@@ -517,6 +517,7 @@ const ChannelInviteModalComponent = (props: Props) => {
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 - <InvitationModalLink>Invite them to the team</InvitationModalLink>'
values={{
InvitationModalLink: (chunks: string) => (
<InviteModalLink id='customNoOptionsMessageLink'>
<InviteModalLink
id='customNoOptionsMessageLink'
abacChannelPolicyEnforced={props.channel.policy_enforced}
>
{chunks}
</InviteModalLink>
),
@@ -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}
</div>
</div>
</GenericModal>

Просмотреть файл

@@ -33,6 +33,7 @@ exports[`components/cloud_start_trial_btn/cloud_start_trial_btn should match sna
}
>
<InviteAs
canInviteGuests={true}
inviteType="MEMBER"
setInviteAs={[MockFunction]}
titleClass="title"

Просмотреть файл

@@ -70,11 +70,11 @@ export default function AddToChannels(props: Props) {
let placeholderChannelName = props.townSquareDisplayName;
// If the user is in a public or private channel,
// If the user is in a public or private channel and is not abac policy enforced,
// use this channel name as a placeholder.
// Inviting to direct or group message channels
// on a team is not currently supported.
if (props.currentChannel && [Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL].includes(props.currentChannel.type)) {
if (props.currentChannel && [Constants.OPEN_CHANNEL, Constants.PRIVATE_CHANNEL].includes(props.currentChannel.type) && !props.currentChannel.policy_enforced) {
placeholderChannelName = props.currentChannel.display_name;
}

Просмотреть файл

@@ -47,6 +47,7 @@ const searchChannels = (teamId: string, term: string) => {
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);

Просмотреть файл

@@ -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(
<Provider store={store}>
<InvitationModal {...props}/>
</Provider>,
);
// 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');
});
});

Просмотреть файл

@@ -262,15 +262,39 @@ export default class InvitationModal extends React.PureComponent<Props, State> {
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[]) => {

Просмотреть файл

@@ -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(
<Provider store={store}>
<InviteAs {...propsWithCanInviteGuestsFalse}/>
</Provider>,
);
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(
<Provider store={store}>
<InviteAs {...propsWithCanInviteGuestsTrue}/>
</Provider>,
);
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(
<Provider store={store}>
<InviteAs {...props}/>
</Provider>,
);
const guestRadioButton = wrapper.find('input[value="GUEST"]');
expect(guestRadioButton.props().disabled).toBe(true);
});
});

Просмотреть файл

@@ -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);
};

Просмотреть файл

@@ -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)) && (