Unify auth context → role permission config resolution into a single pure utility (#18927)

## Summary

- Consolidates duplicated auth-context-to-role-ID resolution logic
(previously in
`PermissionsService.resolveRolePermissionConfigFromAuthContext` and
`CommonBaseQueryRunnerService.getRoleIdOrThrow`) into a single pure
utility function `resolveRolePermissionConfig` in the ORM layer
- The utility is synchronous and operates on cached data
(`userWorkspaceRoleMap`, `apiKeyRoleMap`) already loaded into the
workspace context — no async calls, no service dependencies
- Adds `apiKeyRoleMap` to `ORMWorkspaceContext` (it was already in the
workspace cache, just not loaded into the ORM context)
- Removes `PermissionsService` dependency from
`NavigationMenuItemRecordIdentifierService`
- Removes `UserRoleService` and `ApiKeyRoleService` injections from
`CommonBaseQueryRunnerService`

## Test plan

- [ ] Existing typecheck passes (`npx nx typecheck twenty-server`)
- [ ] Verify record identifier resolution still works for navigation
menu items (user, system, API key, and application auth contexts)
- [ ] Verify GraphQL CRUD queries still enforce correct role-based
permissions
- [ ] Verify API key authenticated requests resolve permissions
correctly


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-24 21:37:58 +01:00
committed by GitHub
parent 37640521d5
commit d5b41b2801
10 changed files with 100 additions and 152 deletions
@@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { type PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-field-value';
@@ -29,9 +28,7 @@ import { OBJECTS_WITH_SETTINGS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/g
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { WorkspacePreQueryHookPayload } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@@ -48,9 +45,9 @@ import {
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import type { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage';
import { resolveRolePermissionConfig } from 'src/engine/twenty-orm/utils/resolve-role-permission-config.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
@@ -73,10 +70,6 @@ export abstract class CommonBaseQueryRunnerService<
@Inject()
protected readonly permissionsService: PermissionsService;
@Inject()
protected readonly userRoleService: UserRoleService;
@Inject()
protected readonly apiKeyRoleService: ApiKeyRoleService;
@Inject()
protected readonly workspaceCacheService: WorkspaceCacheService;
@Inject()
protected readonly commonResultGettersService: CommonResultGettersService;
@@ -140,7 +133,6 @@ export abstract class CommonBaseQueryRunnerService<
async () =>
this.executeQueryAndEnrichResults(
processedArgs,
authContext,
queryRunnerContext,
commonQueryParser,
),
@@ -205,13 +197,11 @@ export abstract class CommonBaseQueryRunnerService<
private async executeQueryAndEnrichResults(
processedArgs: CommonExtendedInput<Args>,
authContext: WorkspaceAuthContext,
queryRunnerContext: CommonBaseQueryRunnerContext,
commonQueryParser: GraphqlQueryParser,
): Promise<Output> {
const extendedQueryRunnerContext =
await this.prepareExtendedQueryRunnerContextWithGlobalDatasource(
authContext,
queryRunnerContext,
);
@@ -223,7 +213,7 @@ export abstract class CommonBaseQueryRunnerService<
return this.enrichResultsWithGettersAndHooks({
results,
operationName: this.operationName,
authContext,
authContext: extendedQueryRunnerContext.authContext,
flatObjectMetadata: queryRunnerContext.flatObjectMetadata,
flatObjectMetadataMaps: queryRunnerContext.flatObjectMetadataMaps,
flatFieldMetadataMaps: queryRunnerContext.flatFieldMetadataMaps,
@@ -302,49 +292,24 @@ export abstract class CommonBaseQueryRunnerService<
}
}
private async getRoleIdOrThrow(
authContext: WorkspaceAuthContext,
workspaceId: string,
): Promise<string> {
if (isApiKeyAuthContext(authContext)) {
return this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
}
if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
return authContext.application.defaultRoleId;
}
if (isUserAuthContext(authContext)) {
return this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
}
throw new CommonQueryRunnerException(
'Invalid auth context',
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
private async prepareExtendedQueryRunnerContextWithGlobalDatasource(
authContext: WorkspaceAuthContext,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<Omit<CommonExtendedQueryRunnerContext, 'commonQueryParser'>> {
const workspaceId = authContext.workspace.id;
const context = getWorkspaceContext();
const roleId = await this.getRoleIdOrThrow(authContext, workspaceId);
const rolePermissionConfig = resolveRolePermissionConfig({
authContext: context.authContext,
userWorkspaceRoleMap: context.userWorkspaceRoleMap,
apiKeyRoleMap: context.apiKeyRoleMap,
});
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [roleId],
};
if (!rolePermissionConfig) {
throw new CommonQueryRunnerException(
'Invalid auth context',
CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const globalWorkspaceDataSource = this.isReadOnly
? await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSourceReplica()
@@ -357,7 +322,7 @@ export abstract class CommonBaseQueryRunnerService<
return {
...queryRunnerContext,
authContext,
authContext: context.authContext,
workspaceDataSource: globalWorkspaceDataSource,
rolePermissionConfig,
repository,
@@ -4,16 +4,9 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SearchResolver } from 'src/engine/core-modules/search/search.resolver';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@Module({
imports: [
FileModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
UserRoleModule,
PermissionsModule,
],
imports: [FileModule, WorkspaceManyOrAllFlatEntityMapsCacheModule],
providers: [SearchResolver, SearchService],
})
export class SearchModule {}
@@ -4,8 +4,6 @@ import { Args, Query } from '@nestjs/graphql';
import { isDefined } from 'twenty-shared/utils';
import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
@@ -13,14 +11,10 @@ import { SearchResultConnectionDTO } from 'src/engine/core-modules/search/dtos/s
import { SearchApiExceptionFilter } from 'src/engine/core-modules/search/filters/search-api-exception.filter';
import { SearchService } from 'src/engine/core-modules/search/services/search.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@CoreResolver()
@UseFilters(SearchApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
@@ -30,16 +24,11 @@ export class SearchResolver {
constructor(
private readonly searchService: SearchService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly userRoleService: UserRoleService,
private readonly apiKeyRoleService: ApiKeyRoleService,
) {}
@Query(() => SearchResultConnectionDTO)
async search(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Args()
{
searchInput,
@@ -69,29 +58,6 @@ export class SearchResolver {
excludedObjectNameSingulars: excludedObjectNameSingulars ?? [],
});
// TODO: move to a service
let rolePermissionConfig: RolePermissionConfig | undefined;
if (isDefined(apiKey)) {
const roleId = await this.apiKeyRoleService.getRoleIdForApiKeyId(
apiKey.id,
workspace.id,
);
if (isDefined(roleId)) {
rolePermissionConfig = { unionOf: [roleId] };
}
} else if (isDefined(userWorkspaceId)) {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
workspaceId: workspace.id,
});
if (isDefined(roleId)) {
rolePermissionConfig = { unionOf: [roleId] };
}
}
const allRecordsWithObjectMetadataItems =
await this.searchService.getAllRecordsWithObjectMetadataItems({
flatObjectMetadatas: filteredObjectMetadataItems,
@@ -103,7 +69,6 @@ export class SearchResolver {
excludedObjectNameSingulars,
after,
workspaceId: workspace.id,
rolePermissionConfig,
});
return this.searchService.computeSearchObjectResults({
@@ -35,7 +35,8 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage';
import { resolveRolePermissionConfig } from 'src/engine/twenty-orm/utils/resolve-role-permission-config.util';
type LastRanks = { tsRankCD: number; tsRank: number };
@@ -64,12 +65,10 @@ export class SearchService {
filter,
after,
workspaceId,
rolePermissionConfig,
}: {
flatObjectMetadatas: FlatObjectMetadata[];
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
workspaceId: string;
rolePermissionConfig?: RolePermissionConfig;
} & SearchArgs) {
const filteredObjectMetadataItems = this.filterObjectMetadataItems({
flatObjectMetadatas,
@@ -90,6 +89,14 @@ export class SearchService {
objectMetadataItemChunk.map(async (flatObjectMetadata) => {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const context = getWorkspaceContext();
const rolePermissionConfig =
resolveRolePermissionConfig({
authContext: context.authContext,
userWorkspaceRoleMap: context.userWorkspaceRoleMap,
apiKeyRoleMap: context.apiKeyRoleMap,
}) ?? undefined;
const repository =
await this.globalWorkspaceOrmManager.getRepository<ObjectRecord>(
workspaceId,
@@ -10,9 +10,10 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { getMinimalSelectForRecordIdentifier } from 'src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { resolveRolePermissionConfig } from 'src/engine/twenty-orm/utils/resolve-role-permission-config.util';
@Injectable()
export class NavigationMenuItemRecordIdentifierService {
@@ -20,7 +21,6 @@ export class NavigationMenuItemRecordIdentifierService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly fileService: FileService,
private readonly permissionsService: PermissionsService,
) {}
async resolveRecordIdentifier({
@@ -63,18 +63,24 @@ export class NavigationMenuItemRecordIdentifierService {
workspace: { id: workspaceId },
} as WorkspaceAuthContext);
const rolePermissionConfig =
await this.permissionsService.resolveRolePermissionConfigFromAuthContext(
resolvedAuthContext,
);
const record =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const context = getWorkspaceContext();
const rolePermissionConfig = resolveRolePermissionConfig({
authContext: context.authContext,
userWorkspaceRoleMap: context.userWorkspaceRoleMap,
apiKeyRoleMap: context.apiKeyRoleMap,
});
if (!rolePermissionConfig) {
return null;
}
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectMetadata.nameSingular,
rolePermissionConfig ?? { shouldBypassPermissionChecks: true },
rolePermissionConfig,
);
const alias = objectMetadata.nameSingular;
@@ -13,10 +13,6 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isSystemAuthContext } from 'src/engine/core-modules/auth/guards/is-system-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { TOOL_PERMISSION_FLAGS } from 'src/engine/metadata-modules/permissions/constants/tool-permission-flags';
import {
PermissionsException,
@@ -134,48 +130,6 @@ export class PermissionsService {
objectsPermissions: {},
}) as const satisfies UserWorkspacePermissions;
// TODO: this could likely be handled in the ORM layer
public async resolveRolePermissionConfigFromAuthContext(
authContext: WorkspaceAuthContext,
): Promise<RolePermissionConfig | null> {
const workspaceId = authContext.workspace.id;
if (isSystemAuthContext(authContext)) {
return { shouldBypassPermissionChecks: true };
}
if (isApiKeyAuthContext(authContext)) {
const roleId = await this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
return { intersectionOf: [roleId] };
}
if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
return { intersectionOf: [authContext.application.defaultRoleId] };
}
if (isUserAuthContext(authContext)) {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
if (!isDefined(roleId)) {
return null;
}
return { intersectionOf: [roleId] };
}
return null;
}
public async userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId,
@@ -321,6 +321,7 @@ describe('WorkspaceEntityManager', () => {
userWorkspaceRoleMap: {
'user-workspace-id': 'role-id',
},
apiKeyRoleMap: {},
};
setWorkspaceContext(mockWorkspaceContext);
@@ -93,6 +93,7 @@ export class GlobalWorkspaceOrmManager {
rolesPermissions: permissionsPerRoleId,
ORMEntityMetadatas: entityMetadatas,
userWorkspaceRoleMap,
apiKeyRoleMap,
flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
@@ -103,6 +104,7 @@ export class GlobalWorkspaceOrmManager {
'rolesPermissions',
'ORMEntityMetadatas',
'userWorkspaceRoleMap',
'apiKeyRoleMap',
'flatRowLevelPermissionPredicateMaps',
'flatRowLevelPermissionPredicateGroupMaps',
]);
@@ -122,6 +124,7 @@ export class GlobalWorkspaceOrmManager {
permissionsPerRoleId,
entityMetadatas,
userWorkspaceRoleMap,
apiKeyRoleMap,
};
}
@@ -167,6 +170,7 @@ export class GlobalWorkspaceOrmManager {
permissionsPerRoleId: {},
entityMetadatas,
userWorkspaceRoleMap: {},
apiKeyRoleMap: {},
};
}
}
@@ -25,6 +25,7 @@ export type ORMWorkspaceContext = {
permissionsPerRoleId: ObjectsPermissionsByRoleId;
entityMetadatas: EntityMetadata[];
userWorkspaceRoleMap: UserWorkspaceRoleMap;
apiKeyRoleMap: Record<string, string>;
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
};
@@ -0,0 +1,52 @@
import { isDefined } from 'twenty-shared/utils';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isSystemAuthContext } from 'src/engine/core-modules/auth/guards/is-system-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export const resolveRolePermissionConfig = ({
authContext,
userWorkspaceRoleMap,
apiKeyRoleMap,
}: {
authContext: WorkspaceAuthContext;
userWorkspaceRoleMap: UserWorkspaceRoleMap;
apiKeyRoleMap: Record<string, string>;
}): RolePermissionConfig | null => {
if (isSystemAuthContext(authContext)) {
return { shouldBypassPermissionChecks: true };
}
if (isApiKeyAuthContext(authContext)) {
const roleId = apiKeyRoleMap[authContext.apiKey.id];
if (!isDefined(roleId)) {
return null;
}
return { intersectionOf: [roleId] };
}
if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
return { intersectionOf: [authContext.application.defaultRoleId] };
}
if (isUserAuthContext(authContext)) {
const roleId = userWorkspaceRoleMap[authContext.userWorkspaceId];
if (!isDefined(roleId)) {
return null;
}
return { intersectionOf: [roleId] };
}
return null;
};