fix(server): map PermissionsException to proper HTTP status on REST API (#20739)
## Summary `PermissionsException` thrown by `SettingsPermissionGuard` (and other permission code paths) was bubbling up through every typed REST exception filter and landing in the global `UnhandledExceptionFilter`, which falls back to **500** for anything that isn't an `HttpException`. So a forbidden user (e.g. an API key whose role doesn't have `DATA_MODEL`) calling `GET /rest/metadata/objects` got: ``` HTTP/1.1 500 Internal Server Error "Entity performing the request does not have permission" ``` GraphQL already had the right plumbing via `permissionGraphqlApiExceptionHandler` (`ForbiddenError` → 403, `UserInputError` → 400, `NotFoundError` → 404). This PR mirrors it on the REST side. ## What - New util `permissionRestApiExceptionCodeToHttpStatus` mapping every `PermissionsExceptionCode` → HTTP status, with `assertUnreachable` to force explicit handling of future codes. - New filter `PermissionsRestApiExceptionFilter` (`@Catch(PermissionsException)`) that delegates to `HttpExceptionHandlerService.handleError(...)` with the resolved status. - Wired `PermissionsRestApiExceptionFilter` (placed first, so the typed filter wins over any sibling catch-all) into `@UseFilters(...)` of every REST controller that uses `SettingsPermissionGuard` or whose service can throw `PermissionsException`: - `object-metadata`, `field-metadata`, `webhook`, `api-key` - `view`, `view-sort`, `view-group`, `view-filter`, `view-filter-group`, `view-field` - `page-layout`, `page-layout-widget`, `page-layout-tab` - `front-component`, `ai-generate-text` - Unit tests covering 403 / 400 / 404 / 500 mappings. ## Mapping | Code | Status | |------|--------| | `PERMISSION_DENIED`, `NO_AUTHENTICATION_CONTEXT`, `ROLE_LABEL_ALREADY_EXISTS`, `CANNOT_UNASSIGN_LAST_ADMIN`, `CANNOT_UPDATE_SELF_ROLE`, `CANNOT_DELETE_LAST_ADMIN_USER`, `ROLE_NOT_EDITABLE`, `CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT`, `CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT` | **403** | | `INVALID_ARG`, `INVALID_SETTING`, `CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT`, `CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION`, `ONLY_FIELD_RESTRICTION_ALLOWED`, `FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT`, `FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT`, `EMPTY_FIELD_PERMISSION_NOT_ALLOWED`, `ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET`, `ROLE_CANNOT_BE_ASSIGNED_TO_USERS` | **400** | | `ROLE_NOT_FOUND`, `OBJECT_METADATA_NOT_FOUND`, `FIELD_METADATA_NOT_FOUND`, `FIELD_PERMISSION_NOT_FOUND`, `PERMISSION_NOT_FOUND` | **404** | | All remaining "internal" codes (rethrown as-is in GraphQL) | **500** | ## Before <img width="507" height="216" alt="Screenshot 2026-05-19 at 19 26 07" src="https://github.com/user-attachments/assets/21d633aa-7ee8-4923-94e4-7ad57258a29e" /> ## After <img width="610" height="385" alt="Screenshot 2026-05-19 at 19 26 01" src="https://github.com/user-attachments/assets/0103b7ee-7df7-4aef-999a-73c22901afd2" />
This commit is contained in:
+76
@@ -0,0 +1,76 @@
|
||||
import { PermissionsExceptionCode } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { permissionRestApiExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/permissions/utils/permission-rest-api-exception-code-to-http-status.util';
|
||||
|
||||
describe('permissionRestApiExceptionCodeToHttpStatus', () => {
|
||||
it('should return 403 for PERMISSION_DENIED', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
),
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 for role-related forbidden cases', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE,
|
||||
),
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 for NO_AUTHENTICATION_CONTEXT', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT,
|
||||
),
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 400 for INVALID_ARG', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.INVALID_ARG,
|
||||
),
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 for permission validation errors', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED,
|
||||
),
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 404 for ROLE_NOT_FOUND', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.ROLE_NOT_FOUND,
|
||||
),
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 for metadata not found cases', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
),
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 500 for METHOD_NOT_ALLOWED', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.METHOD_NOT_ALLOWED,
|
||||
),
|
||||
).toBe(500);
|
||||
});
|
||||
|
||||
it('should return 500 for internal error codes', () => {
|
||||
expect(
|
||||
permissionRestApiExceptionCodeToHttpStatus(
|
||||
PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND,
|
||||
),
|
||||
).toBe(500);
|
||||
});
|
||||
});
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { PermissionsExceptionCode } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
|
||||
export const permissionRestApiExceptionCodeToHttpStatus = (
|
||||
code: PermissionsExceptionCode,
|
||||
): number => {
|
||||
switch (code) {
|
||||
case PermissionsExceptionCode.PERMISSION_DENIED:
|
||||
case PermissionsExceptionCode.NO_AUTHENTICATION_CONTEXT:
|
||||
case PermissionsExceptionCode.ROLE_LABEL_ALREADY_EXISTS:
|
||||
case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN:
|
||||
case PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE:
|
||||
case PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER:
|
||||
case PermissionsExceptionCode.ROLE_NOT_EDITABLE:
|
||||
case PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT:
|
||||
case PermissionsExceptionCode.CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT:
|
||||
return 403;
|
||||
case PermissionsExceptionCode.INVALID_ARG:
|
||||
case PermissionsExceptionCode.INVALID_SETTING:
|
||||
case PermissionsExceptionCode.CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT:
|
||||
case PermissionsExceptionCode.CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION:
|
||||
case PermissionsExceptionCode.ONLY_FIELD_RESTRICTION_ALLOWED:
|
||||
case PermissionsExceptionCode.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT:
|
||||
case PermissionsExceptionCode.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT:
|
||||
case PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED:
|
||||
case PermissionsExceptionCode.ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET:
|
||||
case PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS:
|
||||
return 400;
|
||||
case PermissionsExceptionCode.ROLE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND:
|
||||
case PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND:
|
||||
case PermissionsExceptionCode.FIELD_PERMISSION_NOT_FOUND:
|
||||
case PermissionsExceptionCode.PERMISSION_NOT_FOUND:
|
||||
return 404;
|
||||
case PermissionsExceptionCode.UPSERT_FIELD_PERMISSION_FAILED:
|
||||
case PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH:
|
||||
case PermissionsExceptionCode.TOO_MANY_ADMIN_CANDIDATES:
|
||||
case PermissionsExceptionCode.USER_WORKSPACE_ALREADY_HAS_ROLE:
|
||||
case PermissionsExceptionCode.ADMIN_ROLE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.DEFAULT_ROLE_CANNOT_BE_DELETED:
|
||||
case PermissionsExceptionCode.WORKSPACE_MEMBER_NOT_FOUND:
|
||||
case PermissionsExceptionCode.UNKNOWN_OPERATION_NAME:
|
||||
case PermissionsExceptionCode.UNKNOWN_REQUIRED_PERMISSION:
|
||||
case PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE:
|
||||
case PermissionsExceptionCode.NO_PERMISSIONS_FOUND_IN_DATASOURCE:
|
||||
case PermissionsExceptionCode.METHOD_NOT_ALLOWED:
|
||||
case PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED:
|
||||
case PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND:
|
||||
case PermissionsExceptionCode.API_KEY_ROLE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED:
|
||||
case PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND:
|
||||
case PermissionsExceptionCode.ROLE_BELONGS_TO_ANOTHER_APPLICATION:
|
||||
return 500;
|
||||
default:
|
||||
return assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Response } from 'express';
|
||||
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { permissionRestApiExceptionCodeToHttpStatus } from 'src/engine/metadata-modules/permissions/utils/permission-rest-api-exception-code-to-http-status.util';
|
||||
import { type CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
@Injectable()
|
||||
@Catch(PermissionsException)
|
||||
export class PermissionsRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
catch(exception: PermissionsException, host: ArgumentsHost) {
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
permissionRestApiExceptionCodeToHttpStatus(exception.code),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user