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:
Weiko
2026-05-20 15:02:58 +02:00
committed by GitHub
parent a2acf88a57
commit 6e5e7963b5
18 changed files with 208 additions and 4 deletions
@@ -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);
});
});
@@ -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);
}
};
@@ -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),
);
}
}