Skip to content
Open
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4ff7d07
chore: migrate users.list endpoint to ajv schema
krishikajain28 Mar 5, 2026
68fcec0
Merge branch 'develop' into fix/migrate-users-list-schema
krishikajain28 Mar 5, 2026
73fe9a7
Merge branch 'develop' into fix/migrate-users-list-schema
krishikajain28 Mar 6, 2026
916fcda
fix: address ai bot reviews and add 403 errorType schema
krishikajain28 Mar 6, 2026
8e5a839
chore: removed messy commented out legacy users.list code
krishikajain28 Mar 6, 2026
0ffe200
Merge branch 'develop' into fix/migrate-users-list-schema
krishikajain28 Mar 6, 2026
c280828
chore: remove legacy users.list manual typing
krishikajain28 Mar 7, 2026
7109c55
Merge branch 'fix/migrate-users-list-schema' of https://github.com/kr…
krishikajain28 Mar 7, 2026
d3d08fb
Merge branch 'develop' into fix/migrate-users-list-schema
krishikajain28 Mar 7, 2026
d12808f
fix: strictly align users.list with Paginated generics and silence le…
krishikajain28 Mar 9, 2026
99b54ec
Merge branch 'RocketChat:develop' into fix/migrate-users-list-schema
krishikajain28 Mar 9, 2026
7e28b91
Merge branch 'develop' into fix/migrate-users-list-schema
krishikajain28 Mar 14, 2026
76106a6
Merge branch 'develop' into fix/migrate-users-list-schema
krishikajain28 Mar 17, 2026
397ed97
fix: remove legacy permissionsRequired and correct response schema
krishikajain28 Mar 18, 2026
a459e5f
Merge branch 'develop' into fix/migrate-users-list-schema
krishikajain28 Mar 18, 2026
c425e69
fix: restore view-d-room permission check to prevent security regression
krishikajain28 Mar 18, 2026
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
264 changes: 136 additions & 128 deletions apps/meteor/app/api/server/v1/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,120 +476,6 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'users.list',
{
authRequired: true,
queryOperations: ['$or', '$and'],
permissionsRequired: ['view-d-room'],
},
{
async get() {
if (
settings.get('API_Apply_permission_view-outside-room_on_users-list') &&
!(await hasPermissionAsync(this.userId, 'view-outside-room'))
) {
return API.v1.forbidden();
}

const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields, query } = await this.parseJsonQuery();

const nonEmptyFields = getNonEmptyFields(fields);

const inclusiveFields = getInclusiveFields(nonEmptyFields);

const inclusiveFieldsKeys = Object.keys(inclusiveFields);

const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info'));

// if user provided a query, validate it with their allowed operators
// otherwise we use the default query (with $regex and $options)
if (
!isValidQuery(
nonEmptyQuery,
[
...inclusiveFieldsKeys,
inclusiveFieldsKeys.includes('emails') && 'emails.address.*',
inclusiveFieldsKeys.includes('username') && 'username.*',
inclusiveFieldsKeys.includes('name') && 'name.*',
inclusiveFieldsKeys.includes('type') && 'type.*',
inclusiveFieldsKeys.includes('customFields') && 'customFields.*',
].filter(Boolean) as string[],
// At this point, we have already validated the user query not containing malicious fields
// On here we are using our own query so we can allow some extra fields
[...this.queryOperations, '$regex', '$options'],
)
) {
throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n'));
}

const actualSort = sort || { username: 1 };

if (sort?.status) {
actualSort.active = sort.status;
}

if (sort?.name) {
actualSort.nameInsensitive = sort.name;
}

const limit =
count !== 0
? [
{
$limit: count,
},
]
: [];

const result = await Users.col
.aggregate<{ sortedResults: IUser[]; totalCount: { total: number }[] }>([
{
$match: nonEmptyQuery,
},
{
$project: inclusiveFields,
},
{
$addFields: {
nameInsensitive: {
$toLower: '$name',
},
},
},
{
$facet: {
sortedResults: [
{
$sort: actualSort,
},
{
$skip: offset,
},
...limit,
],
totalCount: [{ $group: { _id: null, total: { $sum: 1 } } }],
},
},
])
.toArray();

const {
sortedResults: users,
totalCount: [{ total } = { total: 0 }],
} = result[0];

return API.v1.success({
users,
count: users.length,
offset,
total,
});
},
},
);

API.v1.addRoute(
'users.listByStatus',
{
Expand Down Expand Up @@ -837,6 +723,7 @@ const usersEndpoints = API.v1
url: string;
}
>;
success: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

}>({
type: 'object',
properties: {
Expand All @@ -849,19 +736,10 @@ const usersEndpoints = API.v1
additionalProperties: {
type: 'object',
properties: {
blob: {
type: 'string',
},
contentType: {
type: 'string',
},
service: {
type: 'string',
},
url: {
type: 'string',
format: 'uri',
},
blob: { type: 'string' },
contentType: { type: 'string' },
service: { type: 'string' },
url: { type: 'string', format: 'uri' },
},
required: ['blob', 'contentType', 'service', 'url'],
additionalProperties: false,
Expand All @@ -875,9 +753,139 @@ const usersEndpoints = API.v1
},
async function action() {
const suggestions = await getAvatarSuggestionForUser(this.user);

return API.v1.success({ suggestions });
},
)
.get(
'users.list',
{
authRequired: true,
permissionsRequired: ['view-d-room'],
Comment on lines +760 to +762
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to stay consistent with the old API options

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: users.list now has an unconditional view-d-room gate, changing prior setting-controlled access behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/api/server/v1/users.ts, line 762:

<comment>`users.list` now has an unconditional `view-d-room` gate, changing prior setting-controlled access behavior.</comment>

<file context>
@@ -759,7 +759,7 @@ const usersEndpoints = API.v1
 			authRequired: true,
 			queryOperations: ['$or', '$and'],
-
+			permissionsRequired: ['view-d-room'],
 			query: ajv.compile<PaginatedRequest<{ fields?: string; query?: string }>>({
 				type: 'object',
</file context>
Fix with Cubic

query: ajv.compile<{
offset?: string;
count?: string;
sort?: string;
fields?: string;
query?: string;
}>({
type: 'object',
properties: {
offset: { type: 'string', nullable: true },
count: { type: 'string', nullable: true },
sort: { type: 'string', nullable: true },
fields: { type: 'string', nullable: true },
query: { type: 'string', nullable: true },
},
required: [],
additionalProperties: false,
}),
response: {
200: ajv.compile<{
users: IUser[];
count: number;
offset: number;
total: number;
success: boolean;
}>({
type: 'object',
properties: {
users: {
type: 'array',
items: { type: 'object' },
},
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
success: { type: 'boolean', enum: [true] },
},
required: ['users', 'count', 'offset', 'total', 'success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
403: ajv.compile({
type: 'object',
properties: {
success: { type: 'boolean', enum: [false] },
error: { type: 'string' },
errorType: { type: 'string' },
},
required: ['success', 'error'],
additionalProperties: false,
}),
},
},
async function action() {
if (
settings.get('API_Apply_permission_view-outside-room_on_users-list') &&
!(await hasPermissionAsync(this.userId, 'view-outside-room'))
) {
return API.v1.forbidden();
}

const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields, query } = await this.parseJsonQuery();

const nonEmptyFields = getNonEmptyFields(fields);
const inclusiveFields = getInclusiveFields(nonEmptyFields);
const inclusiveFieldsKeys = Object.keys(inclusiveFields);

const nonEmptyQuery = getNonEmptyQuery(query, await hasPermissionAsync(this.userId, 'view-full-other-user-info'));

if (
!isValidQuery(
nonEmptyQuery,
[
...inclusiveFieldsKeys,
inclusiveFieldsKeys.includes('emails') && 'emails.address.*',
inclusiveFieldsKeys.includes('username') && 'username.*',
inclusiveFieldsKeys.includes('name') && 'name.*',
inclusiveFieldsKeys.includes('type') && 'type.*',
inclusiveFieldsKeys.includes('customFields') && 'customFields.*',
].filter(Boolean) as string[],
['$or', '$and', '$regex', '$options'],
)
) {
throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n'));
}

const actualSort = sort || { username: 1 };

if (sort?.status) {
actualSort.active = sort.status;
}

if (sort?.name) {
actualSort.nameInsensitive = sort.name;
}

const limit = count !== 0 ? [{ $limit: count }] : [];

const result = await Users.col
.aggregate<{ sortedResults: IUser[]; totalCount: { total: number }[] }>([
{ $match: nonEmptyQuery },
{ $project: inclusiveFields },
{ $addFields: { nameInsensitive: { $toLower: '$name' } } },
{
$facet: {
sortedResults: [{ $sort: actualSort }, { $skip: offset }, ...limit],
totalCount: [{ $group: { _id: null, total: { $sum: 1 } } }],
},
},
])
.toArray();

const {
sortedResults: users,
totalCount: [{ total } = { total: 0 }],
} = result[0];

return API.v1.success({
users,
count: users.length,
offset,
total,
});
},
);

API.v1.addRoute(
Expand Down