From 1cba0cdf495bc01d8c0e9bbde801d1283911f2d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 2 Jul 2026 14:11:04 +0200 Subject: [PATCH] fix(server): apply row-level security predicates to API key and application principals (#22456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Row-level security predicates were only resolved for **user** principals. For API key and application principals, object-level and field-level permissions were resolved (via `resolveRolePermissionConfig`), but the row-level predicate role was left `undefined`, so: - on the read path, `buildRowLevelPermissionRecordFilter` returned `null` and no `WHERE` clause was added; and - on the write path, `validateRLSPredicatesForRecords` returned early and skipped post-write validation. The result was that a role carrying row-level predicates constrained users as intended, but the same role applied to an API key or installed application was subject only to its object/field permissions — not its row filters. ## Changes - Add `resolveRoleIdFromAuthContext`, a single helper that resolves the effective role id for user, API key, and application principals. - Use it in `applyRowLevelPermissionPredicates` (read) and `validateRLSPredicatesForRecords` (write) so row-level predicates are enforced for all principal types. - Thread `apiKeyRoleMap` through `WorkspaceInternalContext` (it was already available on the ORM workspace context). - Refactor `resolveRolePermissionConfig` to reuse the same helper, so object-, field-, and row-level checks all resolve the role identically. `workspaceMember`-relative predicate values are still only bound for user contexts (API keys/applications have no workspace member), matching existing behaviour. ## Notes - Enterprise-gated RLS code paths only. - Could not run `nx typecheck`/lint in this environment (dependencies not installed); changes reviewed manually. CI will validate. https://claude.ai/code/session_01N2RkG8aMwgfFU2jBghMgCQ --- _Generated by [Claude Code](https://claude.ai/code/session_01N2RkG8aMwgfFU2jBghMgCQ)_ Review in cubic --- .../workspace-entity-manager.spec.ts | 1 + .../workspace-entity-manager.ts | 1 + .../workspace-internal-context.interface.ts | 1 + ...ly-row-level-permission-predicates.util.ts | 12 +++--- .../resolve-role-id-from-auth-context.util.ts | 34 +++++++++++++++++ .../resolve-role-permission-config.util.ts | 37 +++++-------------- ...alidate-rls-predicates-for-records.util.ts | 12 +++--- 7 files changed, 58 insertions(+), 40 deletions(-) create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util.ts diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 74b74a0436..05c53838d3 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -245,6 +245,7 @@ describe('WorkspaceEntityManager', () => { IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED: false, }, userWorkspaceRoleMap: {}, + apiKeyRoleMap: {}, eventEmitterService: { emitMutationEvent: jest.fn(), emitDatabaseBatchEvent: jest.fn(), diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index a815b8f665..26116fbd78 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -112,6 +112,7 @@ export class WorkspaceEntityManager extends EntityManager { objectIdByNameSingular: context.objectIdByNameSingular, featureFlagsMap: context.featureFlagsMap, userWorkspaceRoleMap: context.userWorkspaceRoleMap, + apiKeyRoleMap: context.apiKeyRoleMap, eventEmitterService: this.eventEmitterService, coreDataSource: this.connection.coreDataSource, }; diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts index 7b4eefafe1..a7179940a9 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts @@ -20,6 +20,7 @@ export interface WorkspaceInternalContext { objectIdByNameSingular: Record; featureFlagsMap: Record; userWorkspaceRoleMap: UserWorkspaceRoleMap; + apiKeyRoleMap: Record; eventEmitterService: WorkspaceEventEmitter; coreDataSource: DataSource; } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts index f0ede4a17e..3588ad25e5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts @@ -16,6 +16,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util'; +import { resolveRoleIdFromAuthContext } from 'src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util'; type ApplyRowLevelPermissionPredicatesArgs = { queryBuilder: WorkspaceSelectQueryBuilder; @@ -32,12 +33,11 @@ export const applyRowLevelPermissionPredicates = ({ authContext, featureFlagMap: _featureFlagMap, }: ApplyRowLevelPermissionPredicatesArgs): void => { - const userWorkspaceId = isUserAuthContext(authContext) - ? authContext.userWorkspaceId - : undefined; - const roleId = userWorkspaceId - ? internalContext.userWorkspaceRoleMap[userWorkspaceId] - : undefined; + const roleId = resolveRoleIdFromAuthContext({ + authContext, + userWorkspaceRoleMap: internalContext.userWorkspaceRoleMap, + apiKeyRoleMap: internalContext.apiKeyRoleMap, + }); const recordFilter = buildRowLevelPermissionRecordFilter({ flatRowLevelPermissionPredicateMaps: diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util.ts new file mode 100644 index 0000000000..e95f4bf10a --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util.ts @@ -0,0 +1,34 @@ +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 { 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'; + +export const resolveRoleIdFromAuthContext = ({ + authContext, + userWorkspaceRoleMap, + apiKeyRoleMap, +}: { + authContext: WorkspaceAuthContext; + userWorkspaceRoleMap: UserWorkspaceRoleMap; + apiKeyRoleMap: Record; +}): string | undefined => { + if (isUserAuthContext(authContext)) { + return userWorkspaceRoleMap[authContext.userWorkspaceId]; + } + + if (isApiKeyAuthContext(authContext)) { + return apiKeyRoleMap[authContext.apiKey.id]; + } + + if ( + isApplicationAuthContext(authContext) && + isDefined(authContext.application.defaultRoleId) + ) { + return authContext.application.defaultRoleId; + } + + return undefined; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-permission-config.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-permission-config.util.ts index ee07411aa8..5e1139ee83 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-permission-config.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-permission-config.util.ts @@ -1,12 +1,10 @@ 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'; +import { resolveRoleIdFromAuthContext } from 'src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util'; export const resolveRolePermissionConfig = ({ authContext, @@ -21,32 +19,15 @@ export const resolveRolePermissionConfig = ({ return { shouldBypassPermissionChecks: true }; } - if (isApiKeyAuthContext(authContext)) { - const roleId = apiKeyRoleMap[authContext.apiKey.id]; + const roleId = resolveRoleIdFromAuthContext({ + authContext, + userWorkspaceRoleMap, + apiKeyRoleMap, + }); - if (!isDefined(roleId)) { - return null; - } - - return { intersectionOf: [roleId] }; + if (!isDefined(roleId)) { + return null; } - 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; + return { intersectionOf: [roleId] }; }; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts index 54b19dfbb6..57b8699728 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts @@ -14,6 +14,7 @@ import { } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util'; import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util'; +import { resolveRoleIdFromAuthContext } from 'src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util'; type ValidateRLSPredicatesForRecordsArgs = { records: T[]; @@ -36,12 +37,11 @@ export const validateRLSPredicatesForRecords = ({ return; } - const userWorkspaceId = isUserAuthContext(authContext) - ? authContext.userWorkspaceId - : undefined; - const roleId = userWorkspaceId - ? internalContext.userWorkspaceRoleMap[userWorkspaceId] - : undefined; + const roleId = resolveRoleIdFromAuthContext({ + authContext, + userWorkspaceRoleMap: internalContext.userWorkspaceRoleMap, + apiKeyRoleMap: internalContext.apiKeyRoleMap, + }); if (!roleId) { return;