[NEW] [ENTERPRISE] Restrict the permissions configuration for guest users (#17333)

Co-authored-by: Diego Sampaio <chinello@gmail.com>
pull/17378/head
pierre-lehnen-rc 6 years ago committed by GitHub
parent f1d1951633
commit 0a769744d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      app/authorization/client/index.js
  2. 10
      app/authorization/client/lib/AuthorizationUtils.ts
  3. 2
      app/authorization/client/views/permissions.html
  4. 9
      app/authorization/client/views/permissions.js
  5. 2
      app/authorization/server/index.js
  6. 10
      app/authorization/server/lib/AuthorizationUtils.ts
  7. 8
      app/authorization/server/methods/addPermissionToRole.js
  8. 8
      app/authorization/server/methods/removeRoleFromPermission.js
  9. 4
      app/authorization/server/startup.js
  10. 5
      app/lib/server/functions/saveUser.js
  11. 33
      app/models/server/models/Users.js
  12. 1
      app/statistics/server/lib/statistics.js
  13. 1
      app/ui-admin/client/components/info/UsageSection.js
  14. 5
      app/ui/client/views/app/components/Directory/ChannelsTab.js
  15. 5
      app/ui/client/views/app/components/Directory/UserTab.js
  16. 12
      ee/app/authorization/client/AuthorizationUtils.ts
  17. 1
      ee/app/authorization/client/index.ts
  18. 12
      ee/app/authorization/server/AuthorizationUtils.ts
  19. 1
      ee/app/authorization/server/index.ts
  20. 29
      ee/app/authorization/server/validateUserRoles.js
  21. 13
      ee/app/license/client/index.ts
  22. 2
      ee/app/license/server/index.ts
  23. 30
      ee/app/license/server/license.ts
  24. 5
      ee/app/license/server/methods.ts
  25. 1
      ee/client/index.js
  26. 8
      ee/i18n/en.i18n.json
  27. 1
      ee/server/index.js
  28. 1
      packages/rocketchat-i18n/i18n/en.i18n.json
  29. 30
      server/methods/createDirectMessage.js
  30. 1
      server/startup/migrations/index.js
  31. 16
      server/startup/migrations/v188.js

@ -1,5 +1,6 @@
import { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } from './hasPermission';
import { hasRole } from './hasRole';
import { AuthorizationUtils } from './lib/AuthorizationUtils';
import './usersNameChanged';
import './requiresPermission.html';
import './route';
@ -12,4 +13,5 @@ export {
hasRole,
hasPermission,
userHasAllPermission,
AuthorizationUtils,
};

@ -0,0 +1,10 @@
import { Meteor } from 'meteor/meteor';
export const AuthorizationUtils = class {
static isRoleReadOnly(roleId: string): boolean {
if (!roleId) {
throw new Meteor.Error('invalid-param');
}
return false;
}
};

@ -27,7 +27,7 @@
<td class="permission-name border-component-color" title="{{permissionDescription permission}}">{{permissionName permission}}<br><span class = "id-styler">[ID: {{permission._id}}]</span></td>
{{#each role in allRoles}}
<td class="permission-checkbox border-component-color">
<input type="checkbox" name="perm[{{role._id}}][{{permission._id}}]" class="role-permission" value="1" checked="{{granted permission.roles role}}" data-role="{{role._id}}" data-permission="{{permission._id}}">
<input type="checkbox" name="perm[{{role._id}}][{{permission._id}}]" class="role-permission" value="1" checked="{{granted permission.roles role}}" data-role="{{role._id}}" data-permission="{{permission._id}}" disabled="{{disabled role}}">
</td>
{{else}}
<tr class="table-no-click">

@ -1,16 +1,17 @@
import { Meteor } from 'meteor/meteor';
import _ from 'underscore';
import s from 'underscore.string';
import { Meteor } from 'meteor/meteor';
import { ReactiveDict } from 'meteor/reactive-dict';
import { Tracker } from 'meteor/tracker';
import { Template } from 'meteor/templating';
import { Roles } from '../../../models';
import { Roles } from '../../../models/client';
import { ChatPermissions } from '../lib/ChatPermissions';
import { hasAllPermission } from '../hasPermission';
import { t } from '../../../utils/client';
import { SideNav } from '../../../ui-utils/client/lib/SideNav';
import { CONSTANTS } from '../../lib';
import { AuthorizationUtils } from '../lib/AuthorizationUtils';
import { hasAtLeastOnePermission } from '..';
@ -179,6 +180,10 @@ Template.permissionsTable.helpers({
permissionDescription(permission) {
return t(`${ permission._id }_description`);
},
disabled(role) {
return AuthorizationUtils.isRoleReadOnly(role._id);
},
});
Template.permissionsTable.events({

@ -14,6 +14,7 @@ import {
} from './functions/hasPermission';
import { hasRole } from './functions/hasRole';
import { removeUserFromRoles } from './functions/removeUserFromRoles';
import { AuthorizationUtils } from './lib/AuthorizationUtils';
import './methods/addPermissionToRole';
import './methods/addUserToRole';
import './methods/deleteRole';
@ -36,4 +37,5 @@ export {
hasAllPermission,
hasAtLeastOnePermission,
hasPermission,
AuthorizationUtils,
};

@ -0,0 +1,10 @@
import { Meteor } from 'meteor/meteor';
export const AuthorizationUtils = class {
static isRoleReadOnly(roleId: string): boolean {
if (!roleId) {
throw new Meteor.Error('invalid-param');
}
return false;
}
};

@ -2,10 +2,18 @@ import { Meteor } from 'meteor/meteor';
import { Permissions } from '../../../models/server';
import { hasPermission } from '../functions/hasPermission';
import { AuthorizationUtils } from '../lib/AuthorizationUtils';
import { CONSTANTS } from '../../lib';
Meteor.methods({
'authorization:addPermissionToRole'(permissionId, role) {
if (AuthorizationUtils.isRoleReadOnly(role)) {
throw new Meteor.Error('error-action-not-allowed', 'Role is readonly', {
method: 'authorization:addPermissionToRole',
action: 'Adding_permission',
});
}
const uid = Meteor.userId();
const permission = Permissions.findOneById(permissionId);

@ -2,10 +2,18 @@ import { Meteor } from 'meteor/meteor';
import { Permissions } from '../../../models/server';
import { hasPermission } from '../functions/hasPermission';
import { AuthorizationUtils } from '../lib/AuthorizationUtils';
import { CONSTANTS } from '../../lib';
Meteor.methods({
'authorization:removeRoleFromPermission'(permissionId, role) {
if (AuthorizationUtils.isRoleReadOnly(role)) {
throw new Meteor.Error('error-action-not-allowed', 'Role is readonly', {
method: 'authorization:removeRoleFromPermission',
action: 'Removing_permission',
});
}
const uid = Meteor.userId();
const permission = Permissions.findOneById(permissionId);

@ -70,14 +70,14 @@ Meteor.startup(function() {
{ _id: 'unarchive-room', roles: ['admin'] },
{ _id: 'view-c-room', roles: ['admin', 'user', 'bot', 'app', 'anonymous'] },
{ _id: 'user-generate-access-token', roles: ['admin'] },
{ _id: 'view-d-room', roles: ['admin', 'user', 'bot', 'app'] },
{ _id: 'view-d-room', roles: ['admin', 'user', 'bot', 'app', 'guest'] },
{ _id: 'view-full-other-user-info', roles: ['admin'] },
{ _id: 'view-history', roles: ['admin', 'user', 'anonymous'] },
{ _id: 'view-joined-room', roles: ['guest', 'bot', 'app', 'anonymous'] },
{ _id: 'view-join-code', roles: ['admin'] },
{ _id: 'view-logs', roles: ['admin'] },
{ _id: 'view-other-user-channels', roles: ['admin'] },
{ _id: 'view-p-room', roles: ['admin', 'user', 'anonymous'] },
{ _id: 'view-p-room', roles: ['admin', 'user', 'anonymous', 'guest'] },
{ _id: 'view-privileged-setting', roles: ['admin'] },
{ _id: 'view-room-administration', roles: ['admin'] },
{ _id: 'view-statistics', roles: ['admin'] },

@ -10,6 +10,7 @@ import { getRoles, hasPermission } from '../../../authorization';
import { settings } from '../../../settings';
import { passwordPolicy } from '../lib/passwordPolicy';
import { validateEmailDomain } from '../lib';
import { validateUserRoles } from '../../../../ee/app/authorization/server/validateUserRoles';
import { saveUserIdentity } from './saveUserIdentity';
import { checkEmailAvailability, checkUsernameAvailability, setUserAvatar, setEmail, setStatusText } from '.';
@ -97,6 +98,10 @@ function validateUserData(userId, userData) {
});
}
if (userData.roles) {
validateUserRoles(userId, userData);
}
let nameValidation;
try {

@ -623,9 +623,32 @@ export class Users extends Base {
return this.find({
active: true,
type: { $nin: ['app'] },
roles: { $ne: ['guest'] },
}, options);
}
findActiveLocalGuests(idExceptions = [], options = {}) {
const query = {
active: true,
type: { $nin: ['app'] },
roles: {
$eq: 'guest',
$size: 1,
},
isRemote: { $ne: true },
};
if (idExceptions) {
if (!_.isArray(idExceptions)) {
idExceptions = [idExceptions];
}
query._id = { $nin: idExceptions };
}
return this.find(query, options);
}
findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery = []) {
if (exceptions == null) { exceptions = []; }
if (options == null) { options = {}; }
@ -817,7 +840,11 @@ export class Users extends Base {
}
findActiveRemote(options = {}) {
return this.find({ active: true, isRemote: true }, options);
return this.find({
active: true,
isRemote: true,
roles: { $ne: ['guest'] },
}, options);
}
getSAMLByIdAndSAMLProvider(_id, provider) {
@ -1355,6 +1382,10 @@ Find users to send a message by email if:
return this.findActive().count() - this.findActiveRemote().count();
}
getActiveLocalGuestCount(idExceptions = []) {
return this.findActiveLocalGuests(idExceptions).count();
}
removeOlderResumeTokensByUserId(userId, fromDate) {
this.update(userId, {
$pull: {

@ -62,6 +62,7 @@ export const statistics = {
// User statistics
statistics.totalUsers = Users.find().count();
statistics.activeUsers = Users.getActiveLocalUserCount();
statistics.activeGuests = Users.getActiveLocalGuestCount();
statistics.nonActiveUsers = Users.find({ active: false }).count();
statistics.appUsers = Users.find({ type: 'app' }).count();
statistics.onlineUsers = Meteor.users.find({ statusConnection: 'online' }).count();

@ -15,6 +15,7 @@ export function UsageSection({ statistics, isLoading }) {
<DescriptionList data-qa='usage-list'>
<DescriptionList.Entry label={t('Stats_Total_Users')}>{s(() => statistics.totalUsers)}</DescriptionList.Entry>
<DescriptionList.Entry label={t('Stats_Active_Users')}>{s(() => statistics.activeUsers)}</DescriptionList.Entry>
<DescriptionList.Entry label={t('Stats_Active_Guests')}>{s(() => statistics.activeGuests)}</DescriptionList.Entry>
<DescriptionList.Entry label={t('Stats_App_Users')}>{s(() => statistics.appUsers)}</DescriptionList.Entry>
<DescriptionList.Entry label={t('Stats_Non_Active_Users')}>{s(() => statistics.nonActiveUsers)}</DescriptionList.Entry>
<DescriptionList.Entry label={t('Stats_Total_Connected_Users')}>{s(() => statistics.totalConnectedUsers)}</DescriptionList.Entry>

@ -8,6 +8,7 @@ import { useTranslation } from '../../../../../../../client/contexts/Translation
import { useRoute } from '../../../../../../../client/contexts/RouterContext';
import { useQuery, useFormatDate } from '../hooks';
import { roomTypes } from '../../../../../../utils/client';
import { usePermission } from '../../../../../../../client/contexts/AuthorizationContext';
const style = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' };
@ -49,7 +50,9 @@ export function ChannelsTab() {
const channelRoute = useRoute('channel');
const data = useEndpointData('GET', 'directory', query) || {};
const canViewPublicRooms = usePermission('view-c-room');
const data = (canViewPublicRooms && useEndpointData('GET', 'directory', query)) || { result: [] };
const onClick = useMemo(() => (name) => (e) => {
if (e.type === 'click' || e.key === 'Enter') {

@ -44,7 +44,10 @@ export function UserTab({
const directRoute = useRoute('direct');
const data = useEndpointData('GET', 'directory', query) || {};
const canViewOutsideRoom = usePermission('view-outside-room');
const canViewDM = usePermission('view-d-room');
const data = (canViewOutsideRoom && canViewDM && useEndpointData('GET', 'directory', query)) || { result: [] };
const onClick = useMemo(() => (username) => (e) => {
if (e.type === 'click' || e.key === 'Enter') {

@ -0,0 +1,12 @@
import { AuthorizationUtils } from '../../../../app/authorization/client/lib/AuthorizationUtils';
import { isEnterprise } from '../../license/client';
const { isRoleReadOnly: oldIsRoleReadOnly } = AuthorizationUtils;
AuthorizationUtils.isRoleReadOnly = function(roleId: string): boolean {
if (isEnterprise() && roleId === 'guest') {
return true;
}
return oldIsRoleReadOnly(roleId);
};

@ -0,0 +1 @@
import './AuthorizationUtils';

@ -0,0 +1,12 @@
import { AuthorizationUtils } from '../../../../app/authorization/server/lib/AuthorizationUtils';
import { isEnterprise } from '../../license/server';
const { isRoleReadOnly: oldIsRoleReadOnly } = AuthorizationUtils;
AuthorizationUtils.isRoleReadOnly = function(roleId: string): boolean {
if (isEnterprise() && roleId === 'guest') {
return true;
}
return oldIsRoleReadOnly(roleId);
};

@ -0,0 +1 @@
import './AuthorizationUtils';

@ -0,0 +1,29 @@
import { Meteor } from 'meteor/meteor';
import { isEnterprise, getMaxGuestUsers } from '../../license/server';
import { Users } from '../../../../app/models/server';
export const validateUserRoles = function(userId, userData) {
if (!isEnterprise()) {
return;
}
if (!userData.roles.includes('guest')) {
return;
}
if (userData.roles.length >= 2) {
throw new Meteor.Error('error-guests-cant-have-other-roles', "Guest users can't receive any other role", {
method: 'insertOrUpdateUser',
field: 'Assign_role',
});
}
const guestCount = Users.getActiveLocalGuestCount(userData._id);
if (guestCount >= getMaxGuestUsers()) {
throw new Meteor.Error('error-max-guests-number-reached', 'Maximum number of guests reached.', {
method: 'insertOrUpdateUser',
field: 'Assign_role',
});
}
};

@ -13,6 +13,15 @@ const allModules = new Promise<Set<string>>((resolve, reject) => {
});
});
let isEnterpriseServer = false;
CachedCollectionManager.onLogin(async () => {
try {
isEnterpriseServer = await callMethod('license:isEnterprise');
} catch (e) {
console.error('Error checking if server is Enterprise', e);
}
});
export async function hasLicense(feature: string): Promise<boolean> {
try {
const features = await allModules;
@ -21,3 +30,7 @@ export async function hasLicense(feature: string): Promise<boolean> {
return false;
}
}
export function isEnterprise(): boolean {
return isEnterpriseServer;
}

@ -2,4 +2,4 @@ import './settings';
import './methods';
import './startup';
export { onLicense, overwriteClassOnLicense } from './license';
export { onLicense, overwriteClassOnLicense, isEnterprise, getMaxGuestUsers } from './license';

@ -11,6 +11,8 @@ export interface ILicense {
expiry: string;
maxActiveUsers: number;
modules: string[];
maxGuestUsers: number;
maxRoomsPerGuest: number;
}
export interface IValidLicense {
@ -18,6 +20,8 @@ export interface IValidLicense {
license: ILicense;
}
let maxGuestUsers = 0;
class LicenseClass {
private url: string|null = null;
@ -78,6 +82,10 @@ class LicenseClass {
return this.modules.has(module);
}
hasAnyValidLicense(): boolean {
return this.licenses.some((item) => item.valid);
}
getModules(): string[] {
return [...this.modules];
}
@ -115,6 +123,10 @@ class LicenseClass {
return item;
}
if (license.maxGuestUsers > maxGuestUsers) {
maxGuestUsers = license.maxGuestUsers;
}
this._validModules(license.modules);
console.log('#### License validated:', license.modules.join(', '));
@ -137,10 +149,12 @@ class LicenseClass {
const { license } = item;
console.log('---- License enabled ----');
console.log(' url ->', license.url);
console.log(' expiry ->', license.expiry);
console.log(' maxActiveUsers ->', license.maxActiveUsers);
console.log(' modules ->', license.modules.join(', '));
console.log(' url ->', license.url);
console.log(' expiry ->', license.expiry);
console.log(' maxActiveUsers ->', license.maxActiveUsers);
console.log(' maxGuestUsers ->', license.maxGuestUsers);
console.log(' maxRoomsPerGuest ->', license.maxRoomsPerGuest);
console.log(' modules ->', license.modules.join(', '));
console.log('-------------------------');
});
}
@ -185,6 +199,14 @@ export function hasLicense(feature: string): boolean {
return License.hasModule(feature);
}
export function isEnterprise(): boolean {
return License.hasAnyValidLicense();
}
export function getMaxGuestUsers(): number {
return maxGuestUsers;
}
export function getModules(): string[] {
return License.getModules();
}

@ -1,7 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { hasLicense, getModules } from './license';
import { hasLicense, getModules, isEnterprise } from './license';
Meteor.methods({
'license:hasLicense'(feature: string) {
@ -12,4 +12,7 @@ Meteor.methods({
'license:getModules'() {
return getModules();
},
'license:isEnterprise'() {
return isEnterprise();
},
});

@ -1,4 +1,5 @@
import '../app/auditing/client/index';
import '../app/authorization/client/index';
import '../app/canned-responses/client/index';
import '../app/engagement-dashboard/client/index';
import '../app/license/client/index';

@ -1,5 +1,9 @@
{
"error-canned-response-not-found": "Canned Response Not Found",
"error-forwarding-department-target-not-allowed": "The forwarding to the target department is not allowed.",
"error-guests-cant-have-other-roles": "Guest users can't have any other role.",
"error-invalid-priority": "Invalid priority",
"error-max-guests-number-reached": "You reached the maximum number of guest users allowed by your license. Contact sale@rocket.chat for a new license.",
"error-max-number-simultaneous-chats-reached": "The maximum number of simultaneous chats per agent has been reached.",
"Add_monitor": "Add monitor",
"Available_departments": "Available Departments",
@ -14,9 +18,7 @@
"Enter_a_custom_message": "Enter a custom message",
"Enterprise_License": "Enterprise License",
"Enterprise_License_Description": "If your workspace is registered and license is provided by Rocket.Chat Cloud you don't need to manually update the license here.",
"error-forwarding-department-target-not-allowed": "The forwarding to the target department is not allowed.",
"error-invalid-priority": "Invalid priority",
"Estimated_due_time": "Estimated due time(time in minutes)",
"Estimated_due_time": "Estimated due time (time in minutes)",
"Failed_to_add_monitor": "Failed to add monitor",
"Invalid Canned Response": "Invalid Canned Response",
"Invalid_Department": "Invalid Department",

@ -1,6 +1,7 @@
import '../app/models';
import '../app/api-enterprise/server/index';
import '../app/auditing/server/index';
import '../app/authorization/server/index';
import '../app/canned-responses/server/index';
import '../app/engagement-dashboard/server/index';
import '../app/ldap-enterprise/server/index';

@ -3168,6 +3168,7 @@
"Statistics": "Statistics",
"Statistics_reporting": "Send Statistics to Rocket.Chat",
"Statistics_reporting_Description": "By sending your statistics, you'll help us identify how many instances of Rocket.Chat are deployed, as well as how good the system is behaving, so we can further improve it. Don't worry, as no user information is sent and all the information we receive is kept confidential.",
"Stats_Active_Guests": "Activated Guests",
"Stats_Active_Users": "Activated Users",
"Stats_App_Users": "Rocket.Chat App Users",
"Stats_Avg_Channel_Users": "Average Channel Users",

@ -3,7 +3,7 @@ import { check } from 'meteor/check';
import { settings } from '../../app/settings';
import { hasPermission } from '../../app/authorization';
import { Users } from '../../app/models';
import { Users, Rooms } from '../../app/models';
import { RateLimiter } from '../../app/lib';
import { addUser } from '../../app/federation/server/functions/addUser';
import { createRoom } from '../../app/lib/server';
@ -32,13 +32,6 @@ Meteor.methods({
});
}
if (!hasPermission(Meteor.userId(), 'create-d')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'createDirectMessage',
});
}
const users = usernames.filter((username) => username !== me.username).map((username) => {
let to = Users.findOneByUsernameIgnoringCase(username);
@ -55,6 +48,27 @@ Meteor.methods({
return to;
});
if (!hasPermission(Meteor.userId(), 'create-d')) {
// If the user can't create DMs but can access already existing ones
if (hasPermission(Meteor.userId(), 'view-d-room')) {
// Check if the direct room already exists, then return it
const uids = [me, ...users].map(({ _id }) => _id).sort();
const room = Rooms.findOneDirectRoomContainingAllUserIDs(uids, { fields: { _id: 1 } });
if (room) {
return {
t: 'd',
rid: room._id,
...room,
};
}
}
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'createDirectMessage',
});
}
const { _id: rid, inserted, ...room } = createRoom('d', null, null, [me, ...users], null, { }, { creator: me._id });
return {

@ -184,4 +184,5 @@ import './v184';
import './v185';
import './v186';
import './v187';
import './v188';
import './xrun';

@ -0,0 +1,16 @@
import { Migrations } from '../../../app/migrations/server';
import { Permissions } from '../../../app/models/server';
const newRolePermissions = [
'view-d-room',
'view-p-room',
];
const roleName = 'guest';
Migrations.add({
version: 188,
up() {
Permissions.update({ _id: { $in: newRolePermissions } }, { $addToSet: { roles: roleName } }, { multi: true });
},
});
Loading…
Cancel
Save