15b21570ae
## Context This PR adds the core structure for RLS implementation: - RLS data model - RLS service layer - RLS WorkspaceMigration and Syncable Entity + cache + Validations - RLS resolver layer - ORM layer with RLS Predicate to ORM WHERE clause conversion with workspaceMember record transposition Tests are missing though <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Establishes core row-level permissions infrastructure and enforcement across the stack. > > - Backend: new `rowLevelPermissionPredicate` and `rowLevelPermissionPredicateGroup` entities, TypeORM migration, feature flag `IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED`, flat-entity maps/cache wiring, services and GraphQL resolvers for CRUD, and inclusion of `workspaceMember` in auth context > - ORM: applies row-level permission predicates to SELECT, DELETE, and SOFT DELETE query builders; propagates context through GlobalWorkspaceOrmManager/EntityManager > - GraphQL: generated schema/types/queries/mutations for creating/updating/deleting/fetching predicates and groups > - Frontend: settings page adds a gated "Record-level" section (placeholder) and metadata error handler labels for new entities > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fe955cc4588a92157afa6795fb574189a4be1e93. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
158 lines
5.3 KiB
TypeScript
158 lines
5.3 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
|
|
import { type Request, type Response } from 'express';
|
|
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
|
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
|
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
|
import { getAuthExceptionRestStatus } from 'src/engine/core-modules/auth/utils/get-auth-exception-rest-status.util';
|
|
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
|
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
|
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
|
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
|
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
|
import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant';
|
|
import { bindDataToRequestObject } from 'src/engine/utils/bind-data-to-request-object.util';
|
|
import {
|
|
handleException,
|
|
handleExceptionAndConvertToGraphQLError,
|
|
} from 'src/engine/utils/global-exception-handler.util';
|
|
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
|
import { type CustomException } from 'src/utils/custom-exception';
|
|
|
|
@Injectable()
|
|
export class MiddlewareService {
|
|
constructor(
|
|
private readonly accessTokenService: AccessTokenService,
|
|
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
|
|
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
|
private readonly dataSourceService: DataSourceService,
|
|
private readonly exceptionHandlerService: ExceptionHandlerService,
|
|
private readonly jwtWrapperService: JwtWrapperService,
|
|
) {}
|
|
|
|
public isTokenPresent(request: Request): boolean {
|
|
const token = this.jwtWrapperService.extractJwtFromRequest()(request);
|
|
|
|
return !!token;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
public writeRestResponseOnExceptionCaught(res: Response, error: any) {
|
|
const statusCode = this.getStatus(error);
|
|
|
|
// capture and handle custom exceptions
|
|
handleException({
|
|
exception: error as CustomException,
|
|
exceptionHandlerService: this.exceptionHandlerService,
|
|
statusCode,
|
|
});
|
|
|
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
res.write(
|
|
JSON.stringify({
|
|
statusCode,
|
|
messages: [error?.message || INTERNAL_SERVER_ERROR],
|
|
error: error?.code || ErrorCode.INTERNAL_SERVER_ERROR,
|
|
}),
|
|
);
|
|
|
|
res.end();
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
public writeGraphqlResponseOnExceptionCaught(res: Response, error: any) {
|
|
let errors;
|
|
|
|
if (error instanceof AuthException) {
|
|
try {
|
|
const authFilter = new AuthGraphqlApiExceptionFilter();
|
|
|
|
authFilter.catch(error);
|
|
} catch (transformedError) {
|
|
errors = [transformedError];
|
|
}
|
|
} else {
|
|
errors = [
|
|
handleExceptionAndConvertToGraphQLError(
|
|
error as Error,
|
|
this.exceptionHandlerService,
|
|
),
|
|
];
|
|
}
|
|
|
|
const statusCode = 200;
|
|
|
|
res.writeHead(statusCode, {
|
|
'Content-Type': 'application/json',
|
|
});
|
|
|
|
res.write(
|
|
JSON.stringify({
|
|
errors,
|
|
}),
|
|
);
|
|
|
|
res.end();
|
|
}
|
|
|
|
public async hydrateRestRequest(request: Request) {
|
|
const data = await this.accessTokenService.validateTokenByRequest(request);
|
|
const metadataVersion = data.workspace
|
|
? await this.workspaceStorageCacheService.getMetadataVersion(
|
|
data.workspace.id,
|
|
)
|
|
: undefined;
|
|
|
|
const dataSourcesMetadata = data.workspace
|
|
? await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
|
|
data.workspace.id,
|
|
)
|
|
: undefined;
|
|
|
|
if (!dataSourcesMetadata || dataSourcesMetadata.length === 0) {
|
|
throw new Error('No data sources found');
|
|
}
|
|
|
|
bindDataToRequestObject(data, request, metadataVersion);
|
|
}
|
|
|
|
public async hydrateGraphqlRequest(request: Request) {
|
|
if (!this.isTokenPresent(request)) {
|
|
request.locale =
|
|
(request.headers['x-locale'] as keyof typeof APP_LOCALES) ??
|
|
SOURCE_LOCALE;
|
|
|
|
return;
|
|
}
|
|
|
|
const data = await this.accessTokenService.validateTokenByRequest(request);
|
|
const metadataVersion = data.workspace
|
|
? await this.workspaceStorageCacheService.getMetadataVersion(
|
|
data.workspace.id,
|
|
)
|
|
: undefined;
|
|
|
|
bindDataToRequestObject(data, request, metadataVersion);
|
|
}
|
|
|
|
private hasErrorStatus(error: unknown): error is { status: number } {
|
|
return isDefined((error as { status: number })?.status);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private getStatus(error: any): number {
|
|
if (this.hasErrorStatus(error)) {
|
|
return error.status;
|
|
}
|
|
|
|
if (error instanceof AuthException) {
|
|
return getAuthExceptionRestStatus(error);
|
|
}
|
|
|
|
return 500;
|
|
}
|
|
}
|