[permissions] Add workspace + security settings permission gates (#10204)

In this PR

- closing https://github.com/twentyhq/core-team-issues/issues/313
- adding permission gates on workspace settings and security settings
- adding integration tests for each of the protected setting and
security
This commit is contained in:
Marie
2025-02-14 17:32:42 +01:00
committed by GitHub
parent db526778e3
commit 12cc61e096
84 changed files with 857 additions and 155 deletions

View File

@ -143,6 +143,23 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
workspaceValidator.assertIsDefinedOrThrow(workspace);
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspace.id,
);
if (permissionsEnabled) {
await this.validateSecurityPermissions({
payload,
userWorkspaceId,
});
await this.validateWorkspacePermissions({
payload,
userWorkspaceId,
});
}
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
await this.validateSubdomainUpdate(payload.subdomain);
}
@ -188,18 +205,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
);
}
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
workspace.id,
);
if (permissionsEnabled) {
await this.validateSecurityPermissions({
payload,
userWorkspaceId,
});
}
try {
return await this.workspaceRepository.save({
...workspace,
@ -248,7 +253,10 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
workspace.id,
);
await this.workspaceManagerService.init(workspace.id);
await this.workspaceManagerService.init({
workspaceId: workspace.id,
userId: user.id,
});
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
await this.workspaceRepository.update(workspace.id, {
@ -346,38 +354,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
return !existingWorkspace;
}
private async validateSecurityPermissions({
payload,
userWorkspaceId,
}: {
payload: Partial<Workspace>;
userWorkspaceId?: string;
}) {
if (
isDefined(payload.isGoogleAuthEnabled) ||
isDefined(payload.isMicrosoftAuthEnabled) ||
isDefined(payload.isPasswordAuthEnabled) ||
isDefined(payload.isPublicInviteLinkEnabled)
) {
if (!userWorkspaceId) {
throw new Error('Missing userWorkspaceId in authContext');
}
const userHasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
_setting: SettingsFeatures.SECURITY,
});
if (!userHasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
}
async checkCustomDomainValidRecords(workspace: Workspace) {
if (!workspace.customDomain) return;
@ -398,4 +374,68 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
return customDomainDetails;
}
private async validateSecurityPermissions({
payload,
userWorkspaceId,
}: {
payload: Partial<Workspace>;
userWorkspaceId?: string;
}) {
if (
'isGoogleAuthEnabled' in payload ||
'isMicrosoftAuthEnabled' in payload ||
'isPasswordAuthEnabled' in payload ||
'isPublicInviteLinkEnabled' in payload
) {
if (!userWorkspaceId) {
throw new Error('Missing userWorkspaceId in authContext');
}
const userHasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
_setting: SettingsFeatures.SECURITY,
});
if (!userHasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
}
private async validateWorkspacePermissions({
payload,
userWorkspaceId,
}: {
payload: Partial<Workspace>;
userWorkspaceId?: string;
}) {
if (
'displayName' in payload ||
'subdomain' in payload ||
'customDomain' in payload ||
'logo' in payload
) {
if (!userWorkspaceId) {
throw new Error('Missing userWorkspaceId in authContext');
}
const userHasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
_setting: SettingsFeatures.WORKSPACE,
});
if (!userHasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
}
}

View File

@ -12,7 +12,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { isDefined } from 'twenty-shared';
import { isDefined, SettingsFeatures } from 'twenty-shared';
import { Repository } from 'typeorm';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
@ -43,6 +43,7 @@ import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-worksp
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@ -120,7 +121,10 @@ export class WorkspaceResolver {
}
@Mutation(() => String)
@UseGuards(WorkspaceAuthGuard)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(SettingsFeatures.WORKSPACE),
)
async uploadWorkspaceLogo(
@AuthWorkspace() { id }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
@ -161,7 +165,10 @@ export class WorkspaceResolver {
}
@Mutation(() => Workspace)
@UseGuards(WorkspaceAuthGuard)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(SettingsFeatures.WORKSPACE),
)
async deleteCurrentWorkspace(@AuthWorkspace() { id }: Workspace) {
return this.workspaceService.deleteWorkspace(id);
}

View File

@ -14,6 +14,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@ -53,7 +54,7 @@ export const SettingsPermissionsGuard = (
}
throw new PermissionsException(
'User is not authorized to perform this action',
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}

View File

@ -0,0 +1 @@
export const MEMBER_ROLE_LABEL = 'Member';

View File

@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
import { MEMBER_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/member-role-label.constants';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
export class RoleService {
@ -33,4 +34,18 @@ export class RoleService {
workspaceId,
});
}
public async createMemberRole({
workspaceId,
}: {
workspaceId: string;
}): Promise<RoleEntity> {
return this.roleRepository.save({
label: MEMBER_ROLE_LABEL,
description: 'Member role',
canUpdateAllSettings: false,
isEditable: false,
workspaceId,
});
}
}

View File

@ -1,18 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import isEmpty from 'lodash.isempty';
import { Repository } from 'typeorm';
import { DEV_SEED_USER_WORKSPACE_IDS } from 'src/database/typeorm-seeds/core/user-workspaces';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
@ -53,7 +49,13 @@ export class WorkspaceManagerService {
* @param workspaceId
* @returns Promise<void>
*/
public async init(workspaceId: string): Promise<void> {
public async init({
workspaceId,
userId,
}: {
workspaceId: string;
userId: string;
}): Promise<void> {
const schemaName =
await this.workspaceDataSourceService.createWorkspaceDBSchema(
workspaceId,
@ -74,7 +76,7 @@ export class WorkspaceManagerService {
await this.permissionsService.isPermissionsEnabled();
if (permissionsEnabled === true) {
await this.initPermissions(workspaceId);
await this.initPermissions({ workspaceId, userId });
}
await this.prefillWorkspaceWithStandardObjects(
@ -129,7 +131,7 @@ export class WorkspaceManagerService {
await this.permissionsService.isPermissionsEnabled();
if (permissionsEnabled === true) {
await this.initPermissions(workspaceId);
await this.initPermissionsDev(workspaceId);
}
}
@ -229,30 +231,50 @@ export class WorkspaceManagerService {
await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspaceId);
}
private async initPermissions(workspaceId: string) {
private async initPermissions({
workspaceId,
userId,
}: {
workspaceId: string;
userId: string;
}) {
const adminRole = await this.roleService.createAdminRole({
workspaceId,
});
const userWorkspaces = await this.userWorkspaceRepository.find({
const userWorkspace = await this.userWorkspaceRepository.findOneOrFail({
where: {
workspaceId,
userId,
},
});
if (isEmpty(userWorkspaces)) {
throw new PermissionsException(
'User workspace not found',
PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId,
userWorkspaceId: userWorkspace[0].id,
roleId: adminRole.id,
});
}
for (const userWorkspace of userWorkspaces) {
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId,
userWorkspaceId: userWorkspace.id,
roleId: adminRole.id,
});
}
private async initPermissionsDev(workspaceId: string) {
const adminRole = await this.roleService.createAdminRole({
workspaceId,
});
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId,
userWorkspaceId: DEV_SEED_USER_WORKSPACE_IDS.TIM,
roleId: adminRole.id,
});
const memberRole = await this.roleService.createMemberRole({
workspaceId,
});
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId,
userWorkspaceId: DEV_SEED_USER_WORKSPACE_IDS.JONY,
roleId: memberRole.id,
});
}
}