fix(server): apply row-level security predicates to API key and application principals (#22456)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22456?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-02 14:11:04 +02:00
committed by GitHub
parent 049a98a36a
commit 1cba0cdf49
7 changed files with 58 additions and 40 deletions
@@ -245,6 +245,7 @@ describe('WorkspaceEntityManager', () => {
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED: false,
},
userWorkspaceRoleMap: {},
apiKeyRoleMap: {},
eventEmitterService: {
emitMutationEvent: jest.fn(),
emitDatabaseBatchEvent: jest.fn(),
@@ -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,
};
@@ -20,6 +20,7 @@ export interface WorkspaceInternalContext {
objectIdByNameSingular: Record<string, string>;
featureFlagsMap: Record<FeatureFlagKey, boolean>;
userWorkspaceRoleMap: UserWorkspaceRoleMap;
apiKeyRoleMap: Record<string, string>;
eventEmitterService: WorkspaceEventEmitter;
coreDataSource: DataSource;
}
@@ -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<T extends ObjectLiteral> = {
queryBuilder: WorkspaceSelectQueryBuilder<T>;
@@ -32,12 +33,11 @@ export const applyRowLevelPermissionPredicates = <T extends ObjectLiteral>({
authContext,
featureFlagMap: _featureFlagMap,
}: ApplyRowLevelPermissionPredicatesArgs<T>): 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:
@@ -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, string>;
}): 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;
};
@@ -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] };
};
@@ -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<T extends ObjectLiteral> = {
records: T[];
@@ -36,12 +37,11 @@ export const validateRLSPredicatesForRecords = <T extends ObjectLiteral>({
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;