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:
+1
@@ -245,6 +245,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED: false,
|
||||
},
|
||||
userWorkspaceRoleMap: {},
|
||||
apiKeyRoleMap: {},
|
||||
eventEmitterService: {
|
||||
emitMutationEvent: jest.fn(),
|
||||
emitDatabaseBatchEvent: jest.fn(),
|
||||
|
||||
+1
@@ -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,
|
||||
};
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
+6
-6
@@ -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:
|
||||
|
||||
+34
@@ -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;
|
||||
};
|
||||
+9
-28
@@ -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] };
|
||||
};
|
||||
|
||||
+6
-6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user