[permissions] Enforce object-records permission checks in resolvers (#10304)

Closes https://github.com/twentyhq/core-team-issues/issues/393

- enforcing object-records permission checks in resolvers for now. we
will move the logic to a lower level asap
- add integration tests that will still be useful when we have moved the
logic
- introduce guest seeded role to test limited permissions on
object-records
This commit is contained in:
Marie
2025-02-19 11:21:03 +01:00
committed by GitHub
parent 33178fa8b2
commit 861face2a8
48 changed files with 1372 additions and 144 deletions

View File

@ -1,7 +1,12 @@
import { Inject, Injectable } from '@nestjs/common';
import graphqlFields from 'graphql-fields';
import { capitalize, SettingsFeatures } from 'twenty-shared';
import {
capitalize,
isObjectRecordUnderObjectRecordsPermissions,
PermissionsOnAllObjectRecords,
SettingsFeatures,
} from 'twenty-shared';
import { DataSource, ObjectLiteral } from 'typeorm';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
@ -23,6 +28,7 @@ import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-quer
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
import {
AuthException,
AuthExceptionCode,
@ -92,7 +98,20 @@ export abstract class GraphqlQueryBaseResolverService<
featureFlagsMap[FeatureFlagKey.IsPermissionsEnabled] &&
objectMetadataItemWithFieldMaps.isSystem === true
) {
await this.validateSystemObjectPermissions(options);
await this.validateSystemObjectPermissionsOrThrow(options);
}
if (
featureFlagsMap[FeatureFlagKey.IsPermissionsEnabled] &&
isObjectRecordUnderObjectRecordsPermissions({
isCustom: objectMetadataItemWithFieldMaps.isCustom,
nameSingular: objectMetadataItemWithFieldMaps.nameSingular,
})
) {
await this.validateCustomObjectPermissionsOrThrow({
operationName,
options,
});
}
const hookedArgs =
@ -170,7 +189,7 @@ export abstract class GraphqlQueryBaseResolverService<
}
}
private async validateSystemObjectPermissions(
private async validateSystemObjectPermissionsOrThrow(
options: WorkspaceQueryRunnerOptions,
) {
const { authContext, objectMetadataItemWithFieldMaps } = options;
@ -210,6 +229,70 @@ export abstract class GraphqlQueryBaseResolverService<
}
}
private async validateCustomObjectPermissionsOrThrow({
operationName,
options,
}: {
operationName: WorkspaceResolverBuilderMethodNames;
options: WorkspaceQueryRunnerOptions;
}) {
if (!options.authContext.apiKey) {
if (!options.authContext.userWorkspaceId) {
throw new AuthException(
'Missing userWorkspaceId in authContext',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
const requiredPermission =
this.getRequiredPermissionForMethod(operationName);
const userHasPermission =
await this.permissionsService.userHasObjectRecordsPermission({
userWorkspaceId: options.authContext.userWorkspaceId,
requiredPermission,
workspaceId: options.authContext.workspace.id,
});
if (!userHasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
}
private getRequiredPermissionForMethod(
operationName: WorkspaceResolverBuilderMethodNames,
) {
switch (operationName) {
case RESOLVER_METHOD_NAMES.FIND_MANY:
case RESOLVER_METHOD_NAMES.FIND_ONE:
case RESOLVER_METHOD_NAMES.FIND_DUPLICATES:
case RESOLVER_METHOD_NAMES.SEARCH:
return PermissionsOnAllObjectRecords.READ_ALL_OBJECT_RECORDS;
case RESOLVER_METHOD_NAMES.CREATE_MANY:
case RESOLVER_METHOD_NAMES.CREATE_ONE:
case RESOLVER_METHOD_NAMES.UPDATE_MANY:
case RESOLVER_METHOD_NAMES.UPDATE_ONE:
return PermissionsOnAllObjectRecords.UPDATE_ALL_OBJECT_RECORDS;
case RESOLVER_METHOD_NAMES.DELETE_MANY:
case RESOLVER_METHOD_NAMES.DELETE_ONE:
case RESOLVER_METHOD_NAMES.RESTORE_MANY:
case RESOLVER_METHOD_NAMES.RESTORE_ONE:
return PermissionsOnAllObjectRecords.SOFT_DELETE_ALL_OBJECT_RECORDS;
case RESOLVER_METHOD_NAMES.DESTROY_MANY:
case RESOLVER_METHOD_NAMES.DESTROY_ONE:
return PermissionsOnAllObjectRecords.DESTROY_ALL_OBJECT_RECORDS;
default:
throw new PermissionsException(
PermissionsExceptionMessage.UNKNOWN_OPERATION_NAME,
PermissionsExceptionCode.UNKNOWN_OPERATION_NAME,
);
}
}
protected abstract resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<Input>,
featureFlagsMap: Record<FeatureFlagKey, boolean>,

View File

@ -0,0 +1,31 @@
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import {
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const graphqlQueryRunnerExceptionHandler = (
error: GraphqlQueryRunnerException,
) => {
switch (error.code) {
case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_LAST:
case GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.MAX_DEPTH_REACHED:
case GraphqlQueryRunnerExceptionCode.INVALID_CURSOR:
case GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION:
case GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR:
case GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT:
case GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new UserInputError(error.message);
case GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundError(error.message);
default:
throw new InternalServerError(error.message);
}
};

View File

@ -0,0 +1,51 @@
import { isDefined } from 'twenty-shared';
import { QueryFailedError } from 'typeorm';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const handleDuplicateKeyError = (
error: QueryFailedError,
context: WorkspaceQueryRunnerOptions,
) => {
const indexNameMatch = error.message.match(/"([^"]+)"/);
if (indexNameMatch) {
const indexName = indexNameMatch[1];
const deletedAtFieldMetadata =
context.objectMetadataItemWithFieldMaps.fieldsByName['deletedAt'];
const affectedColumns =
context.objectMetadataItemWithFieldMaps.indexMetadatas
.find((index) => index.name === indexName)
?.indexFieldMetadatas?.filter(
(field) => field.fieldMetadataId !== deletedAtFieldMetadata?.id,
)
.map((indexField) => {
const fieldMetadata =
context.objectMetadataItemWithFieldMaps.fieldsById[
indexField.fieldMetadataId
];
return fieldMetadata?.label;
});
if (!isDefined(affectedColumns)) {
throw new UserInputError(`A duplicate entry was detected`);
}
const columnNames = affectedColumns.join(', ');
if (affectedColumns?.length === 1) {
throw new UserInputError(
`Duplicate ${columnNames}. Please set a unique one.`,
);
}
throw new UserInputError(
`A duplicate entry was detected. The combination of ${columnNames} must be unique.`,
);
}
};

View File

@ -0,0 +1,32 @@
import {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
TimeoutError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const workspaceExceptionHandler = (
error: WorkspaceQueryRunnerException,
) => {
switch (error.code) {
case WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND:
throw new NotFoundError(error.message);
case WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new UserInputError(error.message);
case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_UNIQUE_CONSTRAINT:
case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT:
case WorkspaceQueryRunnerExceptionCode.TOO_MANY_ROWS_AFFECTED:
case WorkspaceQueryRunnerExceptionCode.NO_ROWS_AFFECTED:
throw new ForbiddenError(error.message);
case WorkspaceQueryRunnerExceptionCode.QUERY_TIMEOUT:
throw new TimeoutError(error.message);
case WorkspaceQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
default:
throw new InternalServerError(error.message);
}
};

View File

@ -1,114 +1,35 @@
import { QueryFailedError } from 'typeorm';
import { isDefined } from 'twenty-shared';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import {
WorkspaceQueryRunnerException,
WorkspaceQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
TimeoutError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util';
import { handleDuplicateKeyError } from 'src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util';
import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util';
import { WorkspaceQueryRunnerException } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util';
export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
error: Error,
context: WorkspaceQueryRunnerOptions,
) => {
if (error instanceof QueryFailedError) {
if (
error.message.includes('duplicate key value violates unique constraint')
) {
const indexNameMatch = error.message.match(/"([^"]+)"/);
if (indexNameMatch) {
const indexName = indexNameMatch[1];
const deletedAtFieldMetadata =
context.objectMetadataItemWithFieldMaps.fieldsByName['deletedAt'];
const affectedColumns =
context.objectMetadataItemWithFieldMaps.indexMetadatas
.find((index) => index.name === indexName)
?.indexFieldMetadatas?.filter(
(field) => field.fieldMetadataId !== deletedAtFieldMetadata?.id,
)
.map((indexField) => {
const fieldMetadata =
context.objectMetadataItemWithFieldMaps.fieldsById[
indexField.fieldMetadataId
];
return fieldMetadata?.label;
});
if (!isDefined(affectedColumns)) {
throw new UserInputError(`A duplicate entry was detected`);
}
const columnNames = affectedColumns.join(', ');
if (affectedColumns?.length === 1) {
throw new UserInputError(
`Duplicate ${columnNames}. Please set a unique one.`,
);
}
throw new UserInputError(
`A duplicate entry was detected. The combination of ${columnNames} must be unique.`,
);
switch (true) {
case error instanceof QueryFailedError: {
if (
error.message.includes('duplicate key value violates unique constraint')
) {
return handleDuplicateKeyError(error, context);
}
throw error;
}
throw error;
case error instanceof PermissionsException:
return permissionGraphqlApiExceptionHandler(error);
case error instanceof WorkspaceQueryRunnerException:
return workspaceExceptionHandler(error);
case error instanceof GraphqlQueryRunnerException:
return graphqlQueryRunnerExceptionHandler(error);
default:
throw error;
}
if (error instanceof WorkspaceQueryRunnerException) {
switch (error.code) {
case WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND:
throw new NotFoundError(error.message);
case WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new UserInputError(error.message);
case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_UNIQUE_CONSTRAINT:
case WorkspaceQueryRunnerExceptionCode.QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT:
case WorkspaceQueryRunnerExceptionCode.TOO_MANY_ROWS_AFFECTED:
case WorkspaceQueryRunnerExceptionCode.NO_ROWS_AFFECTED:
throw new ForbiddenError(error.message);
case WorkspaceQueryRunnerExceptionCode.QUERY_TIMEOUT:
throw new TimeoutError(error.message);
case WorkspaceQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR:
default:
throw new InternalServerError(error.message);
}
}
if (error instanceof GraphqlQueryRunnerException) {
switch (error.code) {
case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_FIRST:
case GraphqlQueryRunnerExceptionCode.INVALID_ARGS_LAST:
case GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.MAX_DEPTH_REACHED:
case GraphqlQueryRunnerExceptionCode.INVALID_CURSOR:
case GraphqlQueryRunnerExceptionCode.INVALID_DIRECTION:
case GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR:
case GraphqlQueryRunnerExceptionCode.ARGS_CONFLICT:
case GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
throw new UserInputError(error.message);
case GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundError(error.message);
default:
throw new InternalServerError(error.message);
}
}
throw error;
};

View File

@ -0,0 +1,16 @@
export const RESOLVER_METHOD_NAMES = {
FIND_MANY: 'findMany',
FIND_ONE: 'findOne',
FIND_DUPLICATES: 'findDuplicates',
SEARCH: 'search',
CREATE_MANY: 'createMany',
CREATE_ONE: 'createOne',
UPDATE_MANY: 'updateMany',
UPDATE_ONE: 'updateOne',
DELETE_MANY: 'deleteMany',
DELETE_ONE: 'deleteOne',
RESTORE_MANY: 'restoreMany',
RESTORE_ONE: 'restoreOne',
DESTROY_MANY: 'destroyMany',
DESTROY_ONE: 'destroyOne',
} as const;

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class CreateManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'createMany' as const;
public static methodName = RESOLVER_METHOD_NAMES.CREATE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryCreateManyResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class CreateOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'createOne' as const;
public static methodName = RESOLVER_METHOD_NAMES.CREATE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryCreateOneResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryDeleteManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class DeleteManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'deleteMany' as const;
public static methodName = RESOLVER_METHOD_NAMES.DELETE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryDeleteManyResolverService,

View File

@ -9,13 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryDeleteOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class DeleteOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'deleteOne' as const;
public static methodName = RESOLVER_METHOD_NAMES.DELETE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryDeleteOneResolverService,
) {}

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryDestroyManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class DestroyManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'destroyMany' as const;
public static methodName = RESOLVER_METHOD_NAMES.DESTROY_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryDestroyManyResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class DestroyOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'destroyOne' as const;
public static methodName = RESOLVER_METHOD_NAMES.DESTROY_ONE;
constructor(
private readonly graphQLQueryRunnerService: GraphqlQueryDestroyOneResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class FindDuplicatesResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'findDuplicates' as const;
public static methodName = RESOLVER_METHOD_NAMES.FIND_DUPLICATES;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryFindDuplicatesResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class FindManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'findMany' as const;
public static methodName = RESOLVER_METHOD_NAMES.FIND_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryFindManyResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class FindOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'findOne' as const;
public static methodName = RESOLVER_METHOD_NAMES.FIND_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryFindOneResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class RestoreManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'restoreMany' as const;
public static methodName = RESOLVER_METHOD_NAMES.RESTORE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryRestoreManyResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class RestoreOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'restoreOne' as const;
public static methodName = RESOLVER_METHOD_NAMES.RESTORE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryRestoreOneResolverService,

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class SearchResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'search' as const;
public static methodName = RESOLVER_METHOD_NAMES.SEARCH;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQuerySearchResolverService,

View File

@ -9,13 +9,12 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class UpdateManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'updateMany' as const;
public static methodName = RESOLVER_METHOD_NAMES.UPDATE_MANY;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryUpdateManyResolverService,
) {}

View File

@ -9,12 +9,13 @@ import {
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
@Injectable()
export class UpdateOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'updateOne' as const;
public static methodName = RESOLVER_METHOD_NAMES.UPDATE_ONE;
constructor(
private readonly graphqlQueryRunnerService: GraphqlQueryUpdateOneResolverService,

View File

@ -16,6 +16,8 @@ export enum PermissionsExceptionCode {
WORKSPACE_MEMBER_NOT_FOUND = 'WORKSPACE_MEMBER_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
CANNOT_UNASSIGN_LAST_ADMIN = 'CANNOT_UNASSIGN_LAST_ADMIN',
UNKNOWN_OPERATION_NAME = 'UNKNOWN_OPERATION_NAME',
UNKNOWN_REQUIRED_PERMISSION = 'UNKNOWN_REQUIRED_PERMISSION',
}
export enum PermissionsExceptionMessage {
@ -28,4 +30,6 @@ export enum PermissionsExceptionMessage {
WORKSPACE_MEMBER_NOT_FOUND = 'Workspace member not found',
ROLE_NOT_FOUND = 'Role not found',
CANNOT_UNASSIGN_LAST_ADMIN = 'Cannot unassign last admin',
UNKNOWN_OPERATION_NAME = 'Unknown operation name, cannot determine required permission',
UNKNOWN_REQUIRED_PERMISSION = 'Unknown required permission',
}

View File

@ -3,6 +3,12 @@ import { Injectable } from '@nestjs/common';
import { PermissionsOnAllObjectRecords, SettingsFeatures } from 'twenty-shared';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@Injectable()
@ -86,7 +92,49 @@ export class PermissionsService {
return false;
}
public async userHasObjectRecordsPermission({
userWorkspaceId,
workspaceId,
requiredPermission,
}: {
userWorkspaceId: string;
workspaceId: string;
requiredPermission: PermissionsOnAllObjectRecords;
}): Promise<boolean> {
const [roleOfUserWorkspace] = await this.userRoleService
.getRolesByUserWorkspaces({
userWorkspaceIds: [userWorkspaceId],
workspaceId,
})
.then((roles) => roles?.get(userWorkspaceId) ?? []);
const roleColumn =
this.getRoleColumnForRequiredPermission(requiredPermission);
return roleOfUserWorkspace?.[roleColumn] === true;
}
public async isPermissionsEnabled(): Promise<boolean> {
return this.environmentService.get('PERMISSIONS_ENABLED') === true;
}
private getRoleColumnForRequiredPermission(
requiredPermission: PermissionsOnAllObjectRecords,
): keyof RoleEntity {
switch (requiredPermission) {
case PermissionsOnAllObjectRecords.READ_ALL_OBJECT_RECORDS:
return 'canReadAllObjectRecords';
case PermissionsOnAllObjectRecords.UPDATE_ALL_OBJECT_RECORDS:
return 'canUpdateAllObjectRecords';
case PermissionsOnAllObjectRecords.SOFT_DELETE_ALL_OBJECT_RECORDS:
return 'canSoftDeleteAllObjectRecords';
case PermissionsOnAllObjectRecords.DESTROY_ALL_OBJECT_RECORDS:
return 'canDestroyAllObjectRecords';
default:
throw new PermissionsException(
PermissionsExceptionMessage.UNKNOWN_REQUIRED_PERMISSION,
PermissionsExceptionCode.UNKNOWN_REQUIRED_PERMISSION,
);
}
}
}

View File

@ -0,0 +1,24 @@
import {
ForbiddenError,
InternalServerError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
export const permissionGraphqlApiExceptionHandler = (
error: PermissionsException,
) => {
switch (error.code) {
case PermissionsExceptionCode.PERMISSION_DENIED:
case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN:
throw new ForbiddenError(error.message);
case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw new NotFoundError(error.message);
default:
throw new InternalServerError(error.message);
}
};

View File

@ -1,27 +1,11 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { permissionGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util';
@Catch(PermissionsException)
export class PermissionsGraphqlApiExceptionFilter implements ExceptionFilter {
catch(exception: PermissionsException) {
switch (exception.code) {
case PermissionsExceptionCode.PERMISSION_DENIED:
case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN:
throw new ForbiddenError(exception.message);
case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw new NotFoundError(exception.message);
default:
throw new InternalServerError(exception.message);
}
return permissionGraphqlApiExceptionHandler(exception);
}
}

View File

@ -56,4 +56,23 @@ export class RoleService {
workspaceId,
});
}
// Only used for dev seeding and testing
public async createGuestRole({
workspaceId,
}: {
workspaceId: string;
}): Promise<RoleEntity> {
return this.roleRepository.save({
label: 'Guest',
description: 'Guest role',
canUpdateAllSettings: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
isEditable: false,
workspaceId,
});
}
}

View File

@ -109,7 +109,7 @@ export class UserRoleService {
}: {
userWorkspaceIds: string[];
workspaceId: string;
}): Promise<Map<string, RoleDTO[]>> {
}): Promise<Map<string, RoleEntity[]>> {
if (!userWorkspaceIds.length) {
return new Map();
}
@ -128,7 +128,7 @@ export class UserRoleService {
return new Map();
}
const rolesMap = new Map<string, RoleDTO[]>();
const rolesMap = new Map<string, RoleEntity[]>();
for (const userWorkspaceId of userWorkspaceIds) {
const userWorkspaceRolesOfUserWorkspace = allUserWorkspaceRoles.filter(

View File

@ -278,6 +278,17 @@ export class WorkspaceManagerService {
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
adminUserWorkspaceId = DEV_SEED_USER_WORKSPACE_IDS.TIM;
memberUserWorkspaceId = DEV_SEED_USER_WORKSPACE_IDS.JONY;
// Create guest role only in this workspace
const guestRole = await this.roleService.createGuestRole({
workspaceId,
});
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId,
userWorkspaceId: DEV_SEED_USER_WORKSPACE_IDS.PHIL,
roleId: guestRole.id,
});
} else if (workspaceId === SEED_ACME_WORKSPACE_ID) {
adminUserWorkspaceId = DEV_SEED_USER_WORKSPACE_IDS.TIM_ACME;
}