Skip to content

Commit

Permalink
feat!: Add permissions to create rooms in teams (#31117)
Browse files Browse the repository at this point in the history
  • Loading branch information
matheusbsilva137 authored and ggazzo committed Oct 17, 2024
1 parent 7da9ec6 commit 4e1f8ef
Show file tree
Hide file tree
Showing 35 changed files with 772 additions and 99 deletions.
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
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
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

0 comments on commit 4e1f8ef

Please sign in to comment.