[permissions] Enable permissions V1 for all workspaces (#11172)

Closes https://github.com/twentyhq/core-team-issues/issues/526

(for reminder: 
1. Make defaultRoleId non-nullable for an active workspace
2. Remove permissions V1 feature flag
3. Set member role as default role for new workspaces

About 1.:
An active workspace's defaultRoleId should never be null.
We can't rely on a simple postgres NOT NULL constraint as defaultRoleId
will always be initially null when the workspace is first created since
the roles do not exist at that time.

Let's add a more complex rule to ensure that

About 3.:
In the first phase of our deploy of permissions, we chose to assign
admin role to all existing users, not to break any existing behavior
with the introduction of the feature (= existing users have less rights
than before).

As we deploy permissions to all existing and future workspaces, let's
set the member role as default role for future workspaces.
)
This commit is contained in:
Marie
2025-03-26 13:51:34 +01:00
committed by GitHub
parent 0f7adedc96
commit 72b4b26e2c
35 changed files with 103 additions and 562 deletions

View File

@ -17,7 +17,6 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -140,15 +139,6 @@ export class BillingResolver {
userWorkspaceId: string;
isExecutedByApiKey: boolean;
}) {
const isPermissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspaceId,
);
if (!isPermissionsEnabled) {
return;
}
if (
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
workspaceId,

View File

@ -9,9 +9,7 @@ type FeatureFlagMetadata = {
export type PublicFeatureFlag = {
key: Extract<
FeatureFlagKey,
| FeatureFlagKey.IsWorkflowEnabled
| FeatureFlagKey.IsPermissionsEnabled
| FeatureFlagKey.IsCustomDomainEnabled
FeatureFlagKey.IsWorkflowEnabled | FeatureFlagKey.IsCustomDomainEnabled
>;
metadata: FeatureFlagMetadata;
};
@ -25,15 +23,6 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
imagePath: 'https://twenty.com/images/lab/is-workflow-enabled.png',
},
},
{
key: FeatureFlagKey.IsPermissionsEnabled,
metadata: {
label: 'Permissions V1',
description:
'Role-based access control system for workspace security management (Admin/Member)',
imagePath: 'https://twenty.com/images/lab/is-permissions-enabled.png',
},
},
...(process.env.CLOUDFLARE_API_KEY
? [
{

View File

@ -12,7 +12,6 @@ export enum FeatureFlagKey {
IsCustomDomainEnabled = 'IS_CUSTOM_DOMAIN_ENABLED',
IsApprovedAccessDomainsEnabled = 'IS_APPROVED_ACCESS_DOMAINS_ENABLED',
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
IsPermissionsEnabled = 'IS_PERMISSIONS_ENABLED',
IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED',
IsPermissionsV2Enabled = 'IS_PERMISSIONS_V2_ENABLED',
}

View File

@ -3,8 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Repository } from 'typeorm';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
@ -12,7 +12,6 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -97,16 +96,11 @@ export class UserService extends TypeOrmQueryService<User> {
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
const isPermissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspaceId,
);
const workspaceMembers = await workspaceDataSource?.query(
`SELECT * FROM ${dataSourceMetadata.schema}."workspaceMember"`,
);
if (isPermissionsEnabled && workspaceMembers.length > 1) {
if (workspaceMembers.length > 1) {
const userWorkspace =
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
userId,

View File

@ -27,8 +27,6 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
@ -83,7 +81,6 @@ export class UserResolver {
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly userRoleService: UserRoleService,
private readonly permissionsService: PermissionsService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Query(() => User)
@ -103,38 +100,31 @@ export class UserResolver {
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspace.id,
const currentUserWorkspace = user.workspaces.find(
(userWorkspace) => userWorkspace.workspace.id === workspace.id,
);
if (permissionsEnabled === true) {
const currentUserWorkspace = user.workspaces.find(
(userWorkspace) => userWorkspace.workspace.id === workspace.id,
);
if (!currentUserWorkspace) {
throw new Error('Current user workspace not found');
}
const { settingsPermissions, objectRecordsPermissions } =
await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId: currentUserWorkspace.id,
workspaceId: workspace.id,
});
const grantedSettingsPermissions: SettingPermissionType[] = (
Object.keys(settingsPermissions) as SettingPermissionType[]
).filter((feature) => settingsPermissions[feature] === true);
const grantedObjectRecordsPermissions = (
Object.keys(objectRecordsPermissions) as PermissionsOnAllObjectRecords[]
).filter((permission) => objectRecordsPermissions[permission] === true);
currentUserWorkspace.settingsPermissions = grantedSettingsPermissions;
currentUserWorkspace.objectRecordsPermissions =
grantedObjectRecordsPermissions;
user.currentUserWorkspace = currentUserWorkspace;
if (!currentUserWorkspace) {
throw new Error('Current user workspace not found');
}
const { settingsPermissions, objectRecordsPermissions } =
await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId: currentUserWorkspace.id,
workspaceId: workspace.id,
});
const grantedSettingsPermissions: SettingPermissionType[] = (
Object.keys(settingsPermissions) as SettingPermissionType[]
).filter((feature) => settingsPermissions[feature] === true);
const grantedObjectRecordsPermissions = (
Object.keys(objectRecordsPermissions) as PermissionsOnAllObjectRecords[]
).filter((permission) => objectRecordsPermissions[permission] === true);
currentUserWorkspace.settingsPermissions = grantedSettingsPermissions;
currentUserWorkspace.objectRecordsPermissions =
grantedObjectRecordsPermissions;
user.currentUserWorkspace = currentUserWorkspace;
return {
...user,
@ -202,37 +192,31 @@ export class UserResolver {
const workspaceMembers: WorkspaceMember[] = [];
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspace.id,
);
let userWorkspacesByUserId = new Map<string, UserWorkspace>();
let rolesByUserWorkspaces = new Map<string, RoleDTO[]>();
if (permissionsEnabled === true) {
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
userId: In(workspaceMemberEntities.map((entity) => entity.userId)),
workspaceId: workspace.id,
},
});
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
userId: In(workspaceMemberEntities.map((entity) => entity.userId)),
workspaceId: workspace.id,
},
});
userWorkspacesByUserId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.userId,
userWorkspace,
]),
);
userWorkspacesByUserId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.userId,
userWorkspace,
]),
);
rolesByUserWorkspaces =
await this.userRoleService.getRolesByUserWorkspaces({
userWorkspaceIds: userWorkspaces.map(
(userWorkspace) => userWorkspace.id,
),
workspaceId: workspace.id,
});
}
rolesByUserWorkspaces = await this.userRoleService.getRolesByUserWorkspaces(
{
userWorkspaceIds: userWorkspaces.map(
(userWorkspace) => userWorkspace.id,
),
workspaceId: workspace.id,
},
);
for (const workspaceMemberEntity of workspaceMemberEntities) {
if (workspaceMemberEntity.avatarUrl) {
@ -246,39 +230,37 @@ export class UserResolver {
const workspaceMember = workspaceMemberEntity as WorkspaceMember;
if (permissionsEnabled === true) {
const userWorkspace = userWorkspacesByUserId.get(
workspaceMemberEntity.userId,
);
const userWorkspace = userWorkspacesByUserId.get(
workspaceMemberEntity.userId,
);
if (!userWorkspace) {
throw new Error('User workspace not found');
}
workspaceMember.userWorkspaceId = userWorkspace.id;
const workspaceMemberRoles = (
rolesByUserWorkspaces.get(userWorkspace.id) ?? []
).map((roleEntity) => {
return {
id: roleEntity.id,
label: roleEntity.label,
canUpdateAllSettings: roleEntity.canUpdateAllSettings,
description: roleEntity.description,
icon: roleEntity.icon,
isEditable: roleEntity.isEditable,
userWorkspaceRoles: roleEntity.userWorkspaceRoles,
canReadAllObjectRecords: roleEntity.canReadAllObjectRecords,
canUpdateAllObjectRecords: roleEntity.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
roleEntity.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: roleEntity.canDestroyAllObjectRecords,
};
});
workspaceMember.roles = workspaceMemberRoles;
if (!userWorkspace) {
throw new Error('User workspace not found');
}
workspaceMember.userWorkspaceId = userWorkspace.id;
const workspaceMemberRoles = (
rolesByUserWorkspaces.get(userWorkspace.id) ?? []
).map((roleEntity) => {
return {
id: roleEntity.id,
label: roleEntity.label,
canUpdateAllSettings: roleEntity.canUpdateAllSettings,
description: roleEntity.description,
icon: roleEntity.icon,
isEditable: roleEntity.isEditable,
userWorkspaceRoles: roleEntity.userWorkspaceRoles,
canReadAllObjectRecords: roleEntity.canReadAllObjectRecords,
canUpdateAllObjectRecords: roleEntity.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
roleEntity.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: roleEntity.canDestroyAllObjectRecords,
};
});
workspaceMember.roles = workspaceMemberRoles;
workspaceMembers.push(workspaceMember);
}

View File

@ -15,7 +15,6 @@ import { CustomDomainService } from 'src/engine/core-modules/domain-manager/serv
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
FileWorkspaceFolderDeletionJob,
@ -154,26 +153,19 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
workspaceValidator.assertIsDefinedOrThrow(workspace);
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspace.id,
);
await this.validateSecurityPermissions({
payload,
userWorkspaceId,
workspaceId: workspace.id,
apiKey,
});
if (permissionsEnabled) {
await this.validateSecurityPermissions({
payload,
userWorkspaceId,
workspaceId: workspace.id,
apiKey,
});
await this.validateWorkspacePermissions({
payload,
userWorkspaceId,
workspaceId: workspace.id,
apiKey,
});
}
await this.validateWorkspacePermissions({
payload,
userWorkspaceId,
workspaceId: workspace.id,
apiKey,
});
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
await this.validateSubdomainUpdate(payload.subdomain);

View File

@ -1,6 +1,7 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import {
Column,
CreateDateColumn,
@ -11,7 +12,6 @@ import {
Relation,
UpdateDateColumn,
} from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';