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
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,
};
};
99 changes: 85 additions & 14 deletions apps/meteor/client/providers/AuthorizationProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import type { IUser } from '@rocket.chat/core-typings';
import { AuthorizationContext, useUserId } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import type { ContextType, ReactNode } from 'react';
import { useMemo, useSyncExternalStore } from 'react';

import { hasPermission, hasAtLeastOnePermission, hasAllPermission, hasRole } from '../../app/authorization/client';
import { createAuthorizationFunctions } from '../../app/authorization/lib/createAuthorizationFunctions';
import { PermissionsCachedStore } from '../cachedStores';
import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory';
import { Roles } from '../stores';
import { Permissions, Roles, Subscriptions, Users } from '../stores';

// Only the slice of IUser that the authorization helpers actually read.
// Snapshotting just `roles` (instead of the full user document) keeps the
// provider from re-rendering on presence/status updates, last-login flips,
// avatar etag changes, etc. — none of which affect any permission answer.
type AuthorizableUser = Pick<IUser, '_id' | 'roles'>;

type AuthorizationProviderProps = {
children?: ReactNode;
};

const noopSubscribe = (): (() => void) => () => undefined;

const subscribeToSubscriptions = (onStoreChange: () => void): (() => void) => Subscriptions.use.subscribe(onStoreChange);

const selectUserRoles = (userId: IUser['_id'] | undefined): AuthorizableUser['roles'] | undefined => {
if (!userId) return undefined;
return Users.use.getState().get(userId)?.roles;
};

const AuthorizationProvider = ({ children }: AuthorizationProviderProps) => {
const isLoading = !PermissionsCachedStore.useReady();
const isReady = PermissionsCachedStore.useReady();

if (isLoading) {
if (!isReady) {
throw (async () => {
PermissionsCachedStore.listen();
await PermissionsCachedStore.init();
Expand All @@ -23,16 +38,72 @@ const AuthorizationProvider = ({ children }: AuthorizationProviderProps) => {

const userId = useUserId();

// Permissions and Roles change infrequently (admin-driven or login-time only);
// observing the whole map is cheap and re-renders propagate the new auth
// answers through context to every consumer.
const permissionsState = useSyncExternalStore(Permissions.use.subscribe, () => Permissions.use.getState());
const rolesState = useSyncExternalStore(Roles.use.subscribe, () => Roles.use.getState());
// For Users, only the current user's `roles` array is relevant for auth
// decisions (hooks dispatch via getCurrentUserId; `userHasAllPermission` with
// an arbitrary userId has no real callers). Subscribing to the full Users map
// would re-render the provider on every presence update for every user. The
// custom getSnapshot returns the same array reference until the current
// user's roles actually change, so useSyncExternalStore short-circuits via
// Object.is and the provider stays still through unrelated user churn.
const currentUserRoles = useSyncExternalStore(Users.use.subscribe, () => selectUserRoles(userId));
// Subscriptions.use is intentionally NOT observed here — it updates on every
// incoming message, member change, and unread-count flip. Subscription-scoped
// permission checks subscribe per-call below.

const auth = useMemo(
() =>
createAuthorizationFunctions({
getCurrentUserId: () => userId,
// Fast path for the only userId hook consumers ever pass; live read for
// any other userId (only userHasAllPermission can reach this branch).
getUserRoles: (id) => (id === userId ? currentUserRoles : Users.use.getState().get(id)?.roles),
getPermission: (id) => permissionsState.get(id),
getRoleScope: (id) => rolesState.get(id)?.scope,
// Read Subscriptions live — reactivity for scoped checks is wired through
// the per-call subscribe returned by queryPermission/queryRole below.
hasSubscriptionRole: (rid, roleId) =>
Subscriptions.use
.getState()
.find((s) => s.rid === rid)
?.roles?.includes(roleId) ?? false,
isReady: () => true,
}),
[userId, currentUserRoles, permissionsState, rolesState],
);

const contextValue = useMemo(
() => ({
queryPermission: createReactiveSubscriptionFactory((permission, scope, scopeRoles) => hasPermission(permission, scope, scopeRoles)),
queryAtLeastOnePermission: createReactiveSubscriptionFactory((permissions, scope) => hasAtLeastOnePermission(permissions, scope)),
queryAllPermissions: createReactiveSubscriptionFactory((permissions, scope) => hasAllPermission(permissions, scope)),
queryRole: createReactiveSubscriptionFactory((role, scope?) => !!userId && hasRole(userId, role, scope)),
(): ContextType<typeof AuthorizationContext> => ({
// Callers without `scope` never touch Subscriptions (the factory short-circuits
// at the role-scope gate). They rely on context-value identity for re-renders
// from Users/Permissions/Roles changes — which is why subscribe is noop.
// Callers with a `scope` (room id) DO touch Subscriptions, so we attach a
// per-call subscribe to that store so they re-evaluate when subscriptions
// for the relevant room flip without dragging the rest of the tree along.
queryPermission: (permission, scope, scopeRoles) => [
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
() => auth.hasPermission(String(permission), scope ? String(scope) : undefined, scopeRoles),
],
queryAtLeastOnePermission: (permissions, scope) => [
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
() => auth.hasAtLeastOnePermission(permissions.map(String), scope ? String(scope) : undefined),
],
queryAllPermissions: (permissions, scope) => [
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
() => auth.hasAllPermission(permissions.map(String), scope ? String(scope) : undefined),
],
queryRole: (role, scope) => [
scope !== undefined ? subscribeToSubscriptions : noopSubscribe,
() => !!userId && auth.hasRole(userId, String(role), scope),
],
getRoles: () => Roles.state.records,
subscribeToRoles: (callback: () => void) => Roles.use.subscribe(callback),
subscribeToRoles: (callback) => Roles.use.subscribe(callback),
}),
[userId],
[auth, userId],
);

return <AuthorizationContext.Provider value={contextValue}>{children}</AuthorizationContext.Provider>;
Expand Down
Loading