Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/fix-presence-comma-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/meteor': patch
'@rocket.chat/rest-typings': patch
---

Fixes the `users.presence` endpoint returning an empty array when called with multiple comma-separated IDs, caused by `ajvQuery` coercing the string into a single-element array after the OpenAPI migration
5 changes: 5 additions & 0 deletions .changeset/neat-trams-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Ensures the Meteor method for translateMessage validates access and types
82 changes: 2 additions & 80 deletions apps/meteor/app/authorization/client/hasPermission.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,3 @@
import type { IUser, IPermission } from '@rocket.chat/core-typings';
import { liveAuthorizationFunctions } from './liveAuthorizationFunctions';

import { hasRole } from './hasRole';
import { PermissionsCachedStore } from '../../../client/cachedStores';
import { watchUserId } from '../../../client/meteor/user';
import { watch } from '../../../client/meteor/watch';
import { Permissions, Users } from '../../../client/stores';
import { AuthorizationUtils } from '../lib/AuthorizationUtils';

const createPermissionValidator =
(quantifier: (predicate: (permissionId: IPermission['_id']) => boolean) => boolean) =>
(permissionIds: IPermission['_id'][], scope: string | undefined, userId: IUser['_id'], scopedRoles?: IPermission['_id'][]): boolean => {
const userRoles = watch(Users.use, (state) => state.get(userId)?.roles);

const checkEachPermission = quantifier.bind(permissionIds);

return checkEachPermission((permissionId) => {
if (userRoles) {
if (AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) {
return false;
}
}

const permission = watch(Permissions.use, (state) => state.get(permissionId));
const roles = permission?.roles ?? [];

return roles.some((roleId) => {
if (scopedRoles?.includes(roleId)) {
return true;
}

return hasRole(userId, roleId, scope);
});
});
};

const atLeastOne = createPermissionValidator(Array.prototype.some);

const all = createPermissionValidator(Array.prototype.every);

const validatePermissions = (
permissions: IPermission['_id'] | IPermission['_id'][],
scope: string | undefined,
predicate: (
permissionIds: IPermission['_id'][],
scope: string | undefined,
userId: IUser['_id'],
scopedRoles?: IPermission['_id'][],
) => boolean,
userId?: IUser['_id'],
scopedRoles?: IPermission['_id'][],
): boolean => {
userId = userId ?? watchUserId() ?? undefined;

if (!userId) {
return false;
}

if (!watch(PermissionsCachedStore.useReady, (state) => state)) {
return false;
}

return predicate(([] as IPermission['_id'][]).concat(permissions), scope, userId, scopedRoles);
};

export const hasAllPermission = (
permissions: IPermission['_id'] | IPermission['_id'][],
scope?: string,
scopedRoles?: IPermission['_id'][],
): boolean => validatePermissions(permissions, scope, all, undefined, scopedRoles);

export const hasAtLeastOnePermission = (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string): boolean =>
validatePermissions(permissions, scope, atLeastOne);

export const userHasAllPermission = (
permissions: IPermission['_id'] | IPermission['_id'][],
scope?: string,
userId?: IUser['_id'],
): boolean => validatePermissions(permissions, scope, all, userId);

export const hasPermission = hasAllPermission;
export const { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } = liveAuthorizationFunctions;
22 changes: 2 additions & 20 deletions apps/meteor/app/authorization/client/hasRole.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,3 @@
import type { IUser, IRole, IRoom } from '@rocket.chat/core-typings';
import { liveAuthorizationFunctions } from './liveAuthorizationFunctions';

import { watch } from '../../../client/meteor/watch';
import { Roles, Subscriptions, Users } from '../../../client/stores';

export const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id']): boolean => {
const roleScope = watch(Roles.use, (state) => state.get(roleId)?.scope ?? 'Users');

switch (roleScope) {
case 'Subscriptions':
if (!scope) return false;

return watch(Subscriptions.use, (state) => state.find((record) => record.rid === scope)?.roles?.includes(roleId) ?? false);

case 'Users':
return watch(Users.use, (state) => state.get(userId)?.roles?.includes(roleId) ?? false);

default:
return false;
}
};
export const { hasRole } = liveAuthorizationFunctions;
25 changes: 25 additions & 0 deletions apps/meteor/app/authorization/client/liveAuthorizationFunctions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { PermissionsCachedStore } from '../../../client/cachedStores';
import { userIdStore } from '../../../client/lib/user';
import { Permissions, Roles, Subscriptions, Users } from '../../../client/stores';
import type { AuthorizationDeps } from '../lib/createAuthorizationFunctions';
import { createAuthorizationFunctions } from '../lib/createAuthorizationFunctions';

// Bind the pure factory to live zustand store accessors. Each accessor reads
// fresh state on every call, so non-React callers (services, lib code, startup
// scripts) keep their previous "always reflects the current store" contract
// without going through Meteor's Tracker. React consumers should use the
// AuthorizationContext instead, which injects React-reactive snapshots.
const liveDeps: AuthorizationDeps = {
getCurrentUserId: () => userIdStore.getState(),
getUserRoles: (userId) => Users.use.getState().get(userId)?.roles,
getPermission: (permissionId) => Permissions.use.getState().get(permissionId),
getRoleScope: (roleId) => Roles.use.getState().get(roleId)?.scope,
hasSubscriptionRole: (rid, roleId) =>
Subscriptions.use
.getState()
.find((s) => s.rid === rid)
?.roles?.includes(roleId) ?? false,
isReady: () => PermissionsCachedStore.useReady.getState(),
};

export const liveAuthorizationFunctions = createAuthorizationFunctions(liveDeps);
101 changes: 101 additions & 0 deletions apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { IPermission, IRole, IUser } from '@rocket.chat/core-typings';

import { AuthorizationUtils } from './AuthorizationUtils';

export type AuthorizationDeps = {
/** The currently logged-in user id, or undefined. */
getCurrentUserId: () => IUser['_id'] | undefined;
/** The role ids assigned to a given user (Users scope). */
getUserRoles: (userId: IUser['_id']) => IRole['_id'][] | undefined;
/** Lookup a permission by id. */
getPermission: (permissionId: IPermission['_id']) => IPermission | undefined;
/** The scope of a role; defaults to 'Users' when the role is unknown. */
getRoleScope: (roleId: IRole['_id']) => IRole['scope'] | undefined;
/** Whether a subscription scoped to `rid` grants `roleId`. */
hasSubscriptionRole: (rid: string, roleId: IRole['_id']) => boolean;
/** Whether the permissions cache is hydrated; otherwise checks short-circuit to false. */
isReady: () => boolean;
};

export type AuthorizationFunctions = {
hasRole: (userId: IUser['_id'], roleId: IRole['_id'], scope?: string) => boolean;
hasAllPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean;
hasAtLeastOnePermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string) => boolean;
/** Alias of hasAllPermission, kept for parity with the previous API. */
hasPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean;
userHasAllPermission: (
permissions: IPermission['_id'] | IPermission['_id'][],
scope: string | undefined,
userId: IUser['_id'],
) => boolean;
};

/**
* Pure factory for the client-side authorization helpers. All store access is
* threaded through the {@link AuthorizationDeps} accessors, so the returned
* functions are testable in isolation and reusable across any state backend.
*/
export const createAuthorizationFunctions = (deps: AuthorizationDeps): AuthorizationFunctions => {
const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: string): boolean => {
const roleScope = deps.getRoleScope(roleId) ?? 'Users';
switch (roleScope) {
case 'Subscriptions':
if (!scope) return false;
return deps.hasSubscriptionRole(scope, roleId);
case 'Users':
return deps.getUserRoles(userId)?.includes(roleId) ?? false;
default:
return false;
}
};

const checkPermissions = (
permissionIds: IPermission['_id'][],
scope: string | undefined,
userId: IUser['_id'],
scopedRoles: IRole['_id'][] | undefined,
quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean,
): boolean => {
const userRoles = deps.getUserRoles(userId);
return quantifier.call(permissionIds, (permissionId) => {
if (userRoles && AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) {
return false;
}
const roles = deps.getPermission(permissionId)?.roles ?? [];
return roles.some((roleId) => {
if (scopedRoles?.includes(roleId)) return true;
return hasRole(userId, roleId, scope);
});
});
};

const validatePermissions = (
permissions: IPermission['_id'] | IPermission['_id'][],
scope: string | undefined,
quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean,
userId: IUser['_id'] | undefined,
scopedRoles?: IRole['_id'][],
): boolean => {
if (!userId) return false;
if (!deps.isReady()) return false;
const ids = ([] as IPermission['_id'][]).concat(permissions);
return checkPermissions(ids, scope, userId, scopedRoles, quantifier);
};

const hasAllPermission: AuthorizationFunctions['hasAllPermission'] = (permissions, scope, scopedRoles) =>
validatePermissions(permissions, scope, Array.prototype.every, deps.getCurrentUserId(), scopedRoles);

const hasAtLeastOnePermission: AuthorizationFunctions['hasAtLeastOnePermission'] = (permissions, scope) =>
validatePermissions(permissions, scope, Array.prototype.some, deps.getCurrentUserId());

const userHasAllPermission: AuthorizationFunctions['userHasAllPermission'] = (permissions, scope, userId) =>
validatePermissions(permissions, scope, Array.prototype.every, userId);

return {
hasRole,
hasAllPermission,
hasAtLeastOnePermission,
hasPermission: hasAllPermission,
userHasAllPermission,
};
};
21 changes: 20 additions & 1 deletion apps/meteor/app/autotranslate/server/methods/translateMessage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { canAccessRoomAsync } from '../../../authorization/server';
import { translateMessage } from '../functions/translateMessage';

declare module '@rocket.chat/ddp-client' {
Expand All @@ -13,6 +16,22 @@ declare module '@rocket.chat/ddp-client' {

Meteor.methods<ServerMethods>({
async 'autoTranslate.translateMessage'(message, targetLanguage) {
return translateMessage(targetLanguage, message);
const userId = Meteor.userId();
if (!userId) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'autoTranslate.translateMessage',
});
}
check(message?._id, String);
check(targetLanguage, String);
const msg = await Messages.findOneById(message._id);
if (!msg) {
throw new Meteor.Error('error-message-not-found', 'Message not found');
}
const room = await Rooms.findOneById(msg.rid);
if (!room || !(await canAccessRoomAsync(room, { _id: userId }))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed');
}
return translateMessage(targetLanguage, msg);
},
});
Loading
Loading