Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions .changeset/rest-401-403-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@rocket.chat/meteor': minor
---

Stops the REST API from answering `401 Unauthorized` for authorization failures, so that a 401 only ever means "no valid session".

Every `error-unauthorized` / `error-not-authorized` throw in the codebase follows an authorization check (`hasPermissionAsync`, `canAccessRoom`, `canDeleteFile`, agent department scope), never a missing session, but the error switch in `server/api/ApiClass.ts` mapped `error-unauthorized` to 401 once the 9.0.0 breaking-change flag turned on — while the permission middleware answered 403 for the very same condition. A client could not infer session state from the status code, which is what broke live sessions in the `sendMessage` / `getReadReceipts` migration.

- `error-unauthorized` / `unauthorized` now always map to 403, dropping the 401 they would have been promoted to in 9.0.0 (403 today, unchanged).
- `error-not-authorized` / `not-authorized` (the dominant permission code, previously unmapped and falling through to 400) map to 403 from 9.0.0.
- `rooms.membersOrderedByRole` (broadcast member list), `rooms.hide` and `rooms.bannedUsers` returned 401 for access/permission failures; they return 403 from 9.0.0, and their response schemas now declare it.
- Removed a dead `if (applyBreakingChanges)` fork in the permissions middleware whose branches were identical.

The unauthenticated side is unchanged here: those checks still throw `error-invalid-user` and answer 400. Splitting them out needs a dedicated error code and is tracked separately.
9 changes: 7 additions & 2 deletions apps/meteor/server/api/ApiClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,12 +898,17 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
switch (e.error) {
case 'error-too-many-requests':
return api.tooManyRequests(typeof e === 'string' ? e : e.message);
// every `error-unauthorized`/`error-not-authorized` throw in the codebase follows an authorization check,
// so they map to 403; 401 is reserved for a missing or invalid session
case 'unauthorized':
case 'error-unauthorized':
return api.forbidden(typeof e === 'string' ? e : e.message);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: rooms.changeArchivationState can now emit 403 for its archive/unarchive permission check, but its declared responses omit 403. Add the forbidden response schema for this and other routes that propagate error-not-authorized, so test validation and generated API contracts match runtime behavior.

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

<comment>`rooms.changeArchivationState` can now emit 403 for its archive/unarchive permission check, but its declared responses omit 403. Add the forbidden response schema for this and other routes that propagate `error-not-authorized`, so test validation and generated API contracts match runtime behavior.</comment>

<file context>
@@ -898,12 +898,17 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
+									// so they map to 403; 401 is reserved for a missing or invalid session
 									case 'unauthorized':
 									case 'error-unauthorized':
+										return api.forbidden(typeof e === 'string' ? e : e.message);
+									case 'not-authorized':
+									case 'error-not-authorized':
</file context>

case 'not-authorized':
case 'error-not-authorized':

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Authorization failures thrown as native Error still return 400 in 9.0.0 because switch (e.error) cannot match their message. Normalize the error code from e.error or e.message before this switch so these existing REST authorization paths reach 403.

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

<comment>Authorization failures thrown as native `Error` still return 400 in 9.0.0 because `switch (e.error)` cannot match their message. Normalize the error code from `e.error` or `e.message` before this switch so these existing REST authorization paths reach 403.</comment>

<file context>
@@ -898,12 +898,17 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
 									case 'error-unauthorized':
+										return api.forbidden(typeof e === 'string' ? e : e.message);
+									case 'not-authorized':
+									case 'error-not-authorized':
 										if (applyBreakingChanges) {
-											return api.unauthorized(typeof e === 'string' ? e : e.message);
</file context>

if (applyBreakingChanges) {
return api.unauthorized(typeof e === 'string' ? e : e.message);
return api.forbidden(typeof e === 'string' ? e : e.message);
}
return api.forbidden(typeof e === 'string' ? e : e.message);
return api.failure(typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e);
case 'forbidden':
case 'error-forbidden':
if (applyBreakingChanges) {
Expand Down
9 changes: 2 additions & 7 deletions apps/meteor/server/api/v1/middlewares/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,8 @@ export const permissionsMiddleware =
}

if (!hasPermission) {
if (applyBreakingChanges) {
const forbidden = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(forbidden.body, forbidden.statusCode);
}

const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(failure.body, failure.statusCode);
const forbidden = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(forbidden.body, forbidden.statusCode);
}

return next();
Expand Down
14 changes: 10 additions & 4 deletions apps/meteor/server/api/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import { saveNotificationSettingsMethod } from '../../meteor-methods/users/saveN
import type { NotificationFieldType } from '../../meteor-methods/users/saveNotificationSettings';
import { roomsGetMethod } from '../../publications/room';
import { settings } from '../../settings';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { applyBreakingChanges, type ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { MultipartUploadHandler } from '../lib/MultipartUploadHandler';
import { composeRoomWithLastMessage } from '../lib/composeRoomWithLastMessage';
Expand Down Expand Up @@ -1134,6 +1134,7 @@ API.v1.get(
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
},
Expand All @@ -1152,7 +1153,8 @@ API.v1.get(
}

if (findResult.broadcast && !(await hasPermissionAsync(this.user, 'view-broadcast-member-list', findResult._id))) {
return API.v1.unauthorized();
// TODO: MAJOR drop the 401 branch — an authorization failure is 403
return applyBreakingChanges ? API.v1.forbidden() : API.v1.unauthorized();
}

// Ensures that role priorities for the specified room are synchronized correctly.
Expand Down Expand Up @@ -1292,13 +1294,15 @@ API.v1.post(
200: successResponseSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { roomId } = this.bodyParams;

if (!(await canAccessRoomIdAsync(roomId, this.userId))) {
return API.v1.unauthorized();
// TODO: MAJOR drop the 401 branch — an authorization failure is 403
return applyBreakingChanges ? API.v1.forbidden() : API.v1.unauthorized();
}

const user = await Users.findOneById(this.userId, { projection: { _id: 1 } });
Expand Down Expand Up @@ -1695,13 +1699,15 @@ export const roomEndpoints = API.v1
response: {
200: roomsBannedUsersResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { roomId } = this.queryParams;

if (!(await canAccessRoomIdAsync(roomId, this.userId))) {
return API.v1.unauthorized();
// TODO: MAJOR drop the 401 branch — an authorization failure is 403
return applyBreakingChanges ? API.v1.forbidden() : API.v1.unauthorized();
}

const { offset, count } = await getPaginationItems(this.queryParams);
Expand Down
23 changes: 23 additions & 0 deletions apps/meteor/tests/end-to-end/api/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4663,6 +4663,7 @@ describe('[Rooms]', () => {
.set(nonMemberCredentials)
.send({ roomId: roomB._id })
.expect('Content-Type', 'application/json')
// TODO: MAJOR 403 — currently 401 until the 9.0.0 breaking changes are applied
.expect(401)
.expect((res) => {
expect(res.body).to.have.property('success', false);
Expand Down Expand Up @@ -5101,6 +5102,28 @@ describe('[Rooms]', () => {
await Promise.all(bannedUsers.map((user) => deleteUser(user)));
});

it('should return forbidden if user does not have access to the room', async () => {
const privateRoom = (await createRoom({ type: 'p', name: `banned-users-private-${Date.now()}-${Math.random()}` })).body.group;
const nonMember = await createUser({ joinDefaultChannels: false });
const nonMemberCredentials = await login(nonMember.username, password);

try {
await request
.get(api('rooms.bannedUsers'))
.set(nonMemberCredentials)
.query({ roomId: privateRoom._id })
.expect('Content-Type', 'application/json')
// TODO: MAJOR 403 — currently 401 until the 9.0.0 breaking changes are applied
.expect(401)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the expected status version-aware.

apps/meteor/server/api/v1/rooms.ts returns 403 when the 9.0.0 breaking-change gate is enabled and 401 otherwise. Hardcoding 401 here will fail on 9.0.0+ and does not validate the new behavior. Assert the status using the same version gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/meteor/tests/end-to-end/api/rooms.ts` at line 5117, Update the status
assertion in the affected end-to-end room API test to use the same 9.0.0
breaking-change version gate as the rooms API implementation: expect 403 when
the gate is enabled and 401 otherwise, so both behaviors are validated.

.expect((res) => {
expect(res.body).to.have.property('success', false);
});
} finally {
await deleteRoom({ type: 'p', roomId: privateRoom._id });
await deleteUser(nonMember);
}
});

it('should list every banned user of the room with only the projected fields', async () => {
const res = await request
.get(api('rooms.bannedUsers'))
Expand Down
Loading