Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Add permissions to create rooms in teams #31117

Merged
merged 30 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1dce0f6
Add room creation permissions
matheusbsilva137 Nov 29, 2023
06dbec1
Add and Update translations
matheusbsilva137 Nov 30, 2023
51d5b96
Use new permissions on the server side
matheusbsilva137 Nov 30, 2023
ab8166a
Use new permissions on the client side
matheusbsilva137 Nov 30, 2023
f65e904
Fix typecheck
matheusbsilva137 Nov 30, 2023
81db258
Update translations
matheusbsilva137 Dec 1, 2023
1f4b5ed
Add end-to-end tests
matheusbsilva137 Dec 1, 2023
2dc58dc
Create changesets
matheusbsilva137 Dec 4, 2023
63199e1
Add permissions to delete team rooms
matheusbsilva137 Dec 8, 2023
b5ca84a
Update changesets
matheusbsilva137 Dec 11, 2023
15221cf
Update translations for deleting team rooms
matheusbsilva137 Dec 12, 2023
e556d3b
Remove outdated translations
matheusbsilva137 Dec 12, 2023
e416f38
Fix permission check to show delete room button on client
matheusbsilva137 Dec 29, 2023
ca7ab38
Throw error when provided team is not found on room creation
matheusbsilva137 Dec 29, 2023
0b8b553
Add test case to ensure team owners can't delete rooms they don't own
matheusbsilva137 Jan 2, 2024
e3d1076
Replace add-team-channel permission by move-room-to-team
matheusbsilva137 Jan 2, 2024
6cc5329
Fix end-to-end tests
matheusbsilva137 Jan 3, 2024
52b52f0
Remove unrelated changes
matheusbsilva137 Mar 28, 2024
1bc0991
Remove exceeding keys
matheusbsilva137 Apr 26, 2024
c22bd69
re-add unrelated translation
matheusbsilva137 Aug 6, 2024
cd80393
remove exceeding translations
matheusbsilva137 Oct 8, 2024
87281f9
add end-to-end UI tests
matheusbsilva137 Oct 11, 2024
bace563
Improve changeset
matheusbsilva137 Oct 11, 2024
1a0a355
apply changes requested
matheusbsilva137 Oct 11, 2024
2480009
Remove exceeding translations
matheusbsilva137 Oct 11, 2024
142c0a6
Remove more exceeding translations
matheusbsilva137 Oct 13, 2024
ac46fff
re-add migration
matheusbsilva137 Oct 15, 2024
7358b42
Improve changeset
matheusbsilva137 Oct 16, 2024
0c741f9
Improve e2e tests
matheusbsilva137 Oct 16, 2024
5e395a5
remove api call from UI
matheusbsilva137 Oct 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/gentle-kings-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@rocket.chat/meteor": major
---

Adds a new set of permissions to provide a more granular control for the creation and deletion of rooms within teams
- `create-team-channel`: controls the creations of public rooms within teams, it is checked within the team's main room scope and overrides the global `create-c` permission check. That is, granting this permission to a role allows users to create channels in teams even if they do not have the permission to create channels globally;
- `create-team-group`: controls the creations of private rooms within teams, it is checked within the team's main room scope and overrides the global `create-p` permission check. That is, granting this permission to a role allows users to create groups in teams even if they do not have the permission to create groups globally;
- `delete-team-channel`: controls the deletion of public rooms within teams, it is checked within the team's main room scope and complements the global `delete-c` permission check. That is, users must have both permissions (`delete-c` in the channel scope and `delete-team-channel` in its team scope) in order to be able to delete a channel in a team;
- `delete-team-group`: controls the deletion of private rooms within teams, it is checked within the team's main room scope and complements the global `delete-p` permission check. That is, users must have both permissions (`delete-p` in the group scope and `delete-team-group` in its team scope) in order to be able to delete a group in a team;;

Renames `add-team-channel` permission (used for adding existing rooms to teams) to `move-room-to-team`, since it is applied to groups and channels.
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@ export const API: {
members?: { key: string; value?: string[] };
customFields?: { key: string; value?: string };
teams?: { key: string; value?: string[] };
teamId?: { key: string; value?: string };
}) => Promise<void>;
execute: (
userId: string,
Expand Down
13 changes: 12 additions & 1 deletion apps/meteor/app/api/server/v1/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,8 +643,15 @@ async function createChannelValidator(params: {
members?: { key: string; value?: string[] };
customFields?: { key: string; value?: string };
teams?: { key: string; value?: string[] };
teamId?: { key: string; value?: string };
}) {
if (!(await hasPermissionAsync(params.user.value, 'create-c'))) {
const teamId = params.teamId?.value;

const team = teamId && (await Team.getInfoById(teamId));
if (
(!teamId && !(await hasPermissionAsync(params.user.value, 'create-c'))) ||
(teamId && team && !(await hasPermissionAsync(params.user.value, 'create-team-channel', team.roomId)))
) {
throw new Error('unauthorized');
}

Expand Down Expand Up @@ -725,6 +732,10 @@ API.v1.addRoute(
value: bodyParams.teams,
key: 'teams',
},
teamId: {
value: bodyParams.extraData?.teamId,
key: 'teamId',
},
});
} catch (e: any) {
if (e.message === 'unauthorized') {
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/app/api/server/v1/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ API.v1.addRoute(
return API.v1.failure('team-does-not-exist');
}

if (!(await hasPermissionAsync(this.userId, 'add-team-channel', team.roomId))) {
if (!(await hasPermissionAsync(this.userId, 'move-room-to-team', team.roomId))) {
return API.v1.unauthorized('error-no-permission-team-channel');
}

Expand Down
6 changes: 5 additions & 1 deletion apps/meteor/app/authorization/server/constant/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,11 @@ export const permissions = [
{ _id: 'edit-team', roles: ['admin', 'owner'] },
{ _id: 'add-team-member', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-team-member', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'add-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'move-room-to-team', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'create-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'create-team-group', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'delete-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'delete-team-group', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'edit-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'remove-team-channel', roles: ['admin', 'owner', 'moderator'] },
{ _id: 'view-all-team-channels', roles: ['admin', 'owner'] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,38 @@ const validators: RoomSettingsValidators = {
return;
}

if (value === 'c' && !(await hasPermissionAsync(userId, 'create-c'))) {
if (value === 'c' && !room.teamId && !(await hasPermissionAsync(userId, 'create-c'))) {
throw new Meteor.Error('error-action-not-allowed', 'Changing a private group to a public channel is not allowed', {
method: 'saveRoomSettings',
action: 'Change_Room_Type',
});
}

if (value === 'p' && !(await hasPermissionAsync(userId, 'create-p'))) {
if (value === 'p' && !room.teamId && !(await hasPermissionAsync(userId, 'create-p'))) {
throw new Meteor.Error('error-action-not-allowed', 'Changing a public channel to a private room is not allowed', {
method: 'saveRoomSettings',
action: 'Change_Room_Type',
});
}

if (!room.teamId) {
return;
}
const team = await Team.getInfoById(room.teamId);

if (value === 'c' && !(await hasPermissionAsync(userId, 'create-team-channel', team?.roomId))) {
throw new Meteor.Error('error-action-not-allowed', `Changing a team's private group to a public channel is not allowed`, {
method: 'saveRoomSettings',
action: 'Change_Room_Type',
});
}

if (value === 'p' && !(await hasPermissionAsync(userId, 'create-team-group', team?.roomId))) {
throw new Meteor.Error('error-action-not-allowed', `Changing a team's public channel to a private room is not allowed`, {
method: 'saveRoomSettings',
action: 'Change_Room_Type',
});
}
},
async encrypted({ userId, value, room, rid }) {
if (value !== room.encrypted) {
Expand Down
15 changes: 12 additions & 3 deletions apps/meteor/app/lib/server/methods/createChannel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ICreatedRoom } from '@rocket.chat/core-typings';
import type { ICreatedRoom, ITeam } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Users, Team } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

Expand Down Expand Up @@ -40,9 +40,18 @@ export const createChannelMethod = async (
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' });
}

if (!(await hasPermissionAsync(userId, 'create-c'))) {
if (extraData.teamId) {
const team = await Team.findOneById<Pick<ITeam, '_id' | 'roomId'>>(extraData.teamId, { projection: { roomId: 1 } });
if (!team) {
throw new Meteor.Error('error-team-not-found', 'The "teamId" param provided does not match any team', { method: 'createChannel' });
}
if (!(await hasPermissionAsync(userId, 'create-team-channel', team.roomId))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createChannel' });
}
} else if (!(await hasPermissionAsync(userId, 'create-c'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createChannel' });
}

return createRoom('c', name, user, members, excludeSelf, readOnly, {
customFields,
...extraData,
Expand Down
20 changes: 15 additions & 5 deletions apps/meteor/app/lib/server/methods/createPrivateGroup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ICreatedRoom, IUser } from '@rocket.chat/core-typings';
import type { ICreatedRoom, IUser, ITeam } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Users, Team } from '@rocket.chat/models';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

Expand All @@ -25,8 +25,8 @@ export const createPrivateGroupMethod = async (
name: string,
members: string[],
readOnly = false,
customFields = {},
extraData = {},
customFields: Record<string, any> = {},
extraData: Record<string, any> = {},
excludeSelf = false,
): Promise<
ICreatedRoom & {
Expand All @@ -36,7 +36,17 @@ export const createPrivateGroupMethod = async (
check(name, String);
check(members, Match.Optional([String]));

if (!(await hasPermissionAsync(user._id, 'create-p'))) {
if (extraData.teamId) {
const team = await Team.findOneById<Pick<ITeam, '_id' | 'roomId'>>(extraData.teamId, { projection: { roomId: 1 } });
if (!team) {
throw new Meteor.Error('error-team-not-found', 'The "teamId" param provided does not match any team', {
method: 'createPrivateGroup',
});
}
if (!(await hasPermissionAsync(user._id, 'create-team-group', team.roomId))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createPrivateGroup' });
}
} else if (!(await hasPermissionAsync(user._id, 'create-p'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createPrivateGroup' });
}

Expand Down
matheusbsilva137 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IRoom } from '@rocket.chat/core-typings';
import {
Box,
Modal,
Expand Down Expand Up @@ -36,6 +37,7 @@ import { useEncryptedRoomDescription } from '../hooks/useEncryptedRoomDescriptio

type CreateChannelModalProps = {
teamId?: string;
mainRoom?: IRoom;
onClose: () => void;
reload?: () => void;
};
Expand All @@ -61,7 +63,7 @@ const getFederationHintKey = (licenseModule: ReturnType<typeof useHasLicenseModu
return 'Federation_Matrix_Federated_Description';
};

const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModalProps): ReactElement => {
const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateChannelModalProps): ReactElement => {
const t = useTranslation();
const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']);
const e2eEnabled = useSetting('E2E_Enable');
Expand All @@ -71,7 +73,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal
const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled;

const canCreateChannel = usePermission('create-c');
const canCreatePrivateChannel = usePermission('create-p');
const canCreateGroup = usePermission('create-p');
const getEncryptedHint = useEncryptedRoomDescription('channel');

const channelNameRegex = useMemo(() => new RegExp(`^${namesValidation}$`), [namesValidation]);
Expand All @@ -82,17 +84,20 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal
const createChannel = useEndpoint('POST', '/v1/channels.create');
const createPrivateChannel = useEndpoint('POST', '/v1/groups.create');

const canCreateTeamChannel = usePermission('create-team-channel', mainRoom?._id);
const canCreateTeamGroup = usePermission('create-team-group', mainRoom?._id);

const dispatchToastMessage = useToastMessageDispatch();

const canOnlyCreateOneType = useMemo(() => {
if (!canCreateChannel && canCreatePrivateChannel) {
if ((!teamId && !canCreateChannel && canCreateGroup) || (teamId && !canCreateTeamChannel && canCreateTeamGroup)) {
return 'p';
}
if (canCreateChannel && !canCreatePrivateChannel) {
if ((!teamId && canCreateChannel && !canCreateGroup) || (teamId && canCreateTeamChannel && !canCreateTeamGroup)) {
return 'c';
}
return false;
}, [canCreateChannel, canCreatePrivateChannel]);
}, [canCreateChannel, canCreateGroup, canCreateTeamChannel, canCreateTeamGroup, teamId]);

const {
register,
Expand Down Expand Up @@ -267,7 +272,7 @@ const CreateChannelModal = ({ teamId = '', onClose, reload }: CreateChannelModal
id={privateId}
aria-describedby={`${privateId}-hint`}
ref={ref}
checked={value}
checked={canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : value}
disabled={!!canOnlyCreateOneType}
onChange={onChange}
/>
Expand Down
17 changes: 14 additions & 3 deletions apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx
matheusbsilva137 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { IRoom, RoomAdminFieldsType } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useRouter, usePermission, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import React from 'react';

import GenericModal from '../../../components/GenericModal';
Expand All @@ -13,14 +13,25 @@ export const useDeleteRoom = (room: IRoom | Pick<IRoom, RoomAdminFieldsType>, {
const router = useRouter();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const hasPermissionToDelete = usePermission(`delete-${room.t}`, room._id);
const canDeleteRoom = isRoomFederated(room) ? false : hasPermissionToDelete;
// eslint-disable-next-line no-nested-ternary
const roomType = 'prid' in room ? 'discussion' : room.teamId && room.teamMain ? 'team' : 'channel';
const isAdminRoute = router.getRouteName() === 'admin-rooms';

const deleteRoomEndpoint = useEndpoint('POST', '/v1/rooms.delete');
const deleteTeamEndpoint = useEndpoint('POST', '/v1/teams.delete');
const teamsInfoEndpoint = useEndpoint('GET', '/v1/teams.info');

const teamId = room.teamId || '';
const { data: teamInfoData } = useQuery(['teamId', teamId], async () => teamsInfoEndpoint({ teamId }), {
keepPreviousData: true,
retry: false,
enabled: room.teamId !== '',
});

const hasPermissionToDeleteRoom = usePermission(`delete-${room.t}`, room._id);
const hasPermissionToDeleteTeamRoom = usePermission(`delete-team-${room.t === 'c' ? 'channel' : 'group'}`, teamInfoData?.teamInfo.roomId);
const isTeamRoom = room.teamId;
const canDeleteRoom = isRoomFederated(room) ? false : hasPermissionToDeleteRoom && (!isTeamRoom || hasPermissionToDeleteTeamRoom);

const deleteRoomMutation = useMutation({
mutationFn: deleteRoomEndpoint,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings';
import { usePermission, useAtLeastOnePermission, useRole } from '@rocket.chat/ui-contexts';
import { usePermission, useAtLeastOnePermission, useRole, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';

import { E2EEState } from '../../../../../../app/e2e/client/E2EEState';
Expand All @@ -12,11 +13,28 @@ const getCanChangeType = (room: IRoom | IRoomWithRetentionPolicy, canCreateChann

export const useEditRoomPermissions = (room: IRoom | IRoomWithRetentionPolicy) => {
const isAdmin = useRole('admin');
const canCreateChannel = usePermission('create-c');
const canCreateGroup = usePermission('create-p');
const e2eeState = useE2EEState();
const isE2EEReady = e2eeState === E2EEState.READY || e2eeState === E2EEState.SAVE_PASSWORD;
const canChangeType = getCanChangeType(room, canCreateChannel, canCreateGroup, isAdmin);
const canCreateChannel = usePermission('create-c');
const canCreateGroup = usePermission('create-p');
const teamsInfoEndpoint = useEndpoint('GET', '/v1/teams.info');

const teamId = room.teamId || '';
const { data: teamInfoData } = useQuery(['teamId', teamId], async () => teamsInfoEndpoint({ teamId }), {
keepPreviousData: true,
retry: false,
enabled: room.teamId !== '',
});

const canCreateTeamChannel = usePermission('create-team-channel', teamInfoData?.teamInfo.roomId);
const canCreateTeamGroup = usePermission('create-team-group', teamInfoData?.teamInfo.roomId);

const canChangeType = getCanChangeType(
room,
teamId ? canCreateTeamChannel : canCreateChannel,
teamId ? canCreateTeamGroup : canCreateGroup,
isAdmin,
);
const canSetReadOnly = usePermission('set-readonly', room._id);
const canSetReactWhenReadOnly = usePermission('set-react-when-readonly', room._id);
const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ const TeamsChannelItem = ({ room, mainRoom, onClickView, reload }: TeamsChannelI

const [showButton, setShowButton] = useState();

const canRemoveTeamChannel = usePermission('remove-team-channel', rid);
const canEditTeamChannel = usePermission('edit-team-channel', rid);
const canDeleteTeamChannel = usePermission(type === 'c' ? 'delete-c' : 'delete-p', rid);
const canRemoveTeamChannel = usePermission('remove-team-channel', mainRoom._id);
const canEditTeamChannel = usePermission('edit-team-channel', mainRoom._id);
const canDeleteChannel = usePermission(`delete-${type}`, rid);
const canDeleteTeamChannel = usePermission(`delete-team-${type === 'c' ? 'channel' : 'group'}`, mainRoom._id);
const canDelete = canDeleteChannel && canDeleteTeamChannel;

const isReduceMotionEnabled = usePrefersReducedMotion();
const handleMenuEvent = {
Expand Down Expand Up @@ -67,7 +69,7 @@ const TeamsChannelItem = ({ room, mainRoom, onClickView, reload }: TeamsChannelI
)}
</Box>
</OptionContent>
{(canRemoveTeamChannel || canEditTeamChannel || canDeleteTeamChannel) && (
{(canRemoveTeamChannel || canEditTeamChannel || canDelete) && (
<OptionMenu onClick={onClick}>
{showButton ? <TeamsChannelItemMenu room={room} mainRoom={mainRoom} reload={reload} /> : <IconButton tiny icon='kebab' />}
</OptionMenu>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { useLocalStorage, useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useSetModal, usePermission } from '@rocket.chat/ui-contexts';
import { useSetModal, usePermission, useAtLeastOnePermission } from '@rocket.chat/ui-contexts';
import React, { useCallback, useMemo, useState } from 'react';

import { useRecordList } from '../../../../hooks/lists/useRecordList';
Expand All @@ -17,7 +17,8 @@ const TeamsChannelsWithData = () => {
const room = useRoom();
const setModal = useSetModal();
const { closeTab } = useRoomToolbox();
const canAddExistingTeam = usePermission('add-team-channel', room._id);
const canAddExistingRoomToTeam = usePermission('move-room-to-team', room._id);
const canCreateRoomInTeam = useAtLeastOnePermission(['create-team-channel', 'create-team-group'], room._id);

const { teamId } = room;

Expand All @@ -44,7 +45,7 @@ const TeamsChannelsWithData = () => {
});

const handleCreateNew = useEffectEvent(() => {
setModal(<CreateChannelWithData teamId={teamId} onClose={() => setModal(null)} reload={reload} />);
setModal(<CreateChannelWithData teamId={teamId} mainRoom={room} onClose={() => setModal(null)} reload={reload} />);
});

const goToRoom = useEffectEvent((room: IRoom) => {
Expand All @@ -62,8 +63,8 @@ const TeamsChannelsWithData = () => {
channels={items}
total={total}
onClickClose={closeTab}
onClickAddExisting={canAddExistingTeam && handleAddExisting}
onClickCreateNew={canAddExistingTeam && handleCreateNew}
onClickAddExisting={canAddExistingRoomToTeam && handleAddExisting}
onClickCreateNew={canCreateRoomInTeam && handleCreateNew}
onClickView={goToRoom}
loadMoreItems={loadMoreItems}
reload={reload}
Expand Down
Loading
Loading