Row level permissions - POC 1 (#16599)

## Context
This PR adds the core structure for RLS implementation:
- RLS data model
- RLS service layer
- RLS WorkspaceMigration and Syncable Entity + cache + Validations
- RLS resolver layer
- ORM layer with RLS Predicate to ORM WHERE clause conversion with
workspaceMember record transposition

Tests are missing though

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Establishes core row-level permissions infrastructure and enforcement
across the stack.
> 
> - Backend: new `rowLevelPermissionPredicate` and
`rowLevelPermissionPredicateGroup` entities, TypeORM migration, feature
flag `IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED`, flat-entity
maps/cache wiring, services and GraphQL resolvers for CRUD, and
inclusion of `workspaceMember` in auth context
> - ORM: applies row-level permission predicates to SELECT, DELETE, and
SOFT DELETE query builders; propagates context through
GlobalWorkspaceOrmManager/EntityManager
> - GraphQL: generated schema/types/queries/mutations for
creating/updating/deleting/fetching predicates and groups
> - Frontend: settings page adds a gated "Record-level" section
(placeholder) and metadata error handler labels for new entities
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
fe955cc4588a92157afa6795fb574189a4be1e93. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Weiko
2025-12-31 16:15:17 +01:00
committed by GitHub
parent 888a50c3be
commit 15b21570ae
120 changed files with 5649 additions and 29 deletions
@@ -23,6 +23,8 @@ describe('JwtAuthStrategy', () => {
let applicationRepository: any;
let jwtWrapperService: any;
let permissionsService: any;
let globalWorkspaceOrmManager: any;
let workspaceMemberRepository: any;
const jwt = {
sub: 'sub-default',
@@ -57,6 +59,20 @@ describe('JwtAuthStrategy', () => {
permissionsService = {
userHasWorkspaceSettingPermission: jest.fn(),
};
workspaceMemberRepository = {
findOne: jest.fn(),
};
workspaceMemberRepository.findOne.mockResolvedValue({
id: 'workspace-member-id',
});
globalWorkspaceOrmManager = {
executeInWorkspaceContext: jest.fn(async (_authContext, callback) => {
return await callback();
}),
getRepository: jest.fn(async () => workspaceMemberRepository),
};
});
afterEach(() => {
@@ -80,6 +96,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -111,6 +128,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -145,6 +163,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -179,6 +198,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
const result = await strategy.validate(payload as JwtPayload);
@@ -220,10 +240,11 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspaceEntity not found', expect.any(String), {
new AuthException('User not found', expect.any(String), {
userFriendlyMessage: msg`User does not have access to this workspace.`,
}),
);
@@ -231,7 +252,7 @@ describe('JwtAuthStrategy', () => {
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
}
});
@@ -261,6 +282,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -306,6 +328,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
const user = await strategy.validate(payload as JwtPayload);
@@ -339,6 +362,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -392,6 +416,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -438,6 +463,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -486,6 +512,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -545,6 +572,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -599,6 +627,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -670,6 +699,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -740,6 +770,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -811,6 +842,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@@ -875,6 +907,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
const result = await strategy.validate(payload as JwtPayload);
@@ -941,6 +974,7 @@ describe('JwtAuthStrategy', () => {
userWorkspaceRepository,
apiKeyRepository,
permissionsService,
globalWorkspaceOrmManager,
);
const result = await strategy.validate(payload as JwtPayload);
@@ -4,11 +4,13 @@ import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { Strategy } from 'passport-jwt';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
AuthException,
AuthExceptionCode,
@@ -29,7 +31,9 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
@@ -46,6 +50,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
private readonly permissionsService: PermissionsService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny
@@ -146,6 +151,13 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
where: { id: userId },
});
if (!isDefined(user)) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
if (!payload.userWorkspaceId) {
throw new AuthException(
'UserWorkspaceEntity not found',
@@ -179,7 +191,50 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
workspaceMemberId: payload.workspaceMemberId,
};
return context;
if (
workspace.activationStatus ===
WorkspaceActivationStatus.PENDING_CREATION ||
workspace.activationStatus === WorkspaceActivationStatus.ONGOING_CREATION
) {
return context;
}
const workspaceMember =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
buildSystemAuthContext(workspace.id),
async () => {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspace.id,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: user.id,
},
});
assertIsDefinedOrThrow(
workspaceMember,
new AuthException(
'User is not a member of the workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
{
userFriendlyMessage: msg`User is not a member of the workspace.`,
},
),
);
return workspaceMember;
},
);
return {
...context,
workspaceMember,
};
}
private async validateImpersonation(payload: AccessTokenJwtPayload) {
@@ -1,14 +1,16 @@
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export type AuthContext = {
user?: UserEntity | null | undefined;
apiKey?: ApiKeyEntity | null | undefined;
workspaceMemberId?: string;
workspaceMember?: WorkspaceMemberWorkspaceEntity;
workspace?: WorkspaceEntity;
application?: ApplicationEntity | null | undefined;
userWorkspaceId?: string;
@@ -60,6 +60,7 @@ import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-mode
import { FlatPageLayoutTabModule } from 'src/engine/metadata-modules/flat-page-layout-tab/flat-page-layout-tab.module';
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@@ -81,6 +82,7 @@ import { FileModule } from './file/file.module';
ClientConfigModule,
FeatureFlagModule,
FileModule,
RowLevelPermissionModule,
OpenApiModule,
ApplicationModule,
ApplicationSyncModule,
@@ -150,6 +152,7 @@ import { FileModule } from './file/file.module';
ImpersonationModule,
TrashCleanupModule,
DashboardModule,
RowLevelPermissionModule,
],
exports: [
AuditModule,
@@ -14,5 +14,6 @@ export enum FeatureFlagKey {
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_TIMELINE_ACTIVITY_MIGRATED = 'IS_TIMELINE_ACTIVITY_MIGRATED',
IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED = 'IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED',
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
IS_WORKSPACE_CREATION_V2_ENABLED = 'IS_WORKSPACE_CREATION_V2_ENABLED',
}