Add comprehensive permission guard coverage across GraphQL and REST endpoints (#15739)

This PR enhances our security model by ensuring all GraphQL resolvers
and REST API endpoints have appropriate permission guards.

## Changes

### ESLint Rules
- Enhanced `graphql-resolvers-should-be-guarded` to require permission
guards on all resolvers (Query, Mutation, Subscription), not just
mutations
- Enhanced `rest-api-methods-should-be-guarded` to require permission
guards on all REST endpoints (GET, POST, PUT, PATCH, DELETE), not just
mutating methods
- Both rules now enforce consistent security: authentication guards +
permission guards for all endpoints

### Permission Guards Added

**Public Endpoints** - Added `NoPermissionGuard`:
- Auth-related queries (checkUserExists, findWorkspaceFromInviteHash,
validatePasswordResetToken)
- Billing webhooks (Stripe callbacks)
- SSO callbacks (SAML authentication)
- Workflow webhooks
- Cloudflare webhooks
- Route trigger endpoints
- GraphQL subscriptions
- Current workspace queries
- Geo-map address autocomplete
- View-related read operations (view-field, view-filter, view-group,
view-sort, view-filter-group)

**Settings Permission Guards** - Added `SettingsPermissionGuard`:
- API Keys management: `PermissionFlagType.API_KEYS_AND_WEBHOOKS`
- Webhooks management: `PermissionFlagType.API_KEYS_AND_WEBHOOKS`
- Page Layouts (write operations): `PermissionFlagType.LAYOUTS`
- REST Metadata API: `PermissionFlagType.DATA_MODEL`
- Agent operations: `PermissionFlagType.AI`
- Remote servers: `PermissionFlagType.DATA_MODEL`
- Remote tables: `PermissionFlagType.DATA_MODEL`
- Serverless functions: `PermissionFlagType.WORKFLOWS`

**Custom Permission Guards** - Added `CustomPermissionGuard`:
- REST Core API (permissions checked at query execution layer)
- Timeline calendar events (permission checks in service layer)
- Timeline messaging (permission checks in service layer)
- Search operations (permission checks in service layer)
- View operations (permission checks via dedicated view permission
guards)

### View Permission Guards
- Created dedicated `FindManyViewsPermissionGuard` and
`FindOneViewPermissionGuard` for reading views
- Created `CreateViewPermissionGuard` for view creation with
visibility-based permission checks
- All view child entities (view-field, view-filter, view-sort,
view-group, view-filter-group) use `NoPermissionGuard` for reads
- Write operations on view child entities use dedicated permission
guards that check parent view access

### Page Layout Permissions
- Read operations (GET/Query) now use `NoPermissionGuard` - users can
view layouts without LAYOUTS permission
- Write operations (POST/PATCH/DELETE/Mutation) require
`SettingsPermissionGuard(PermissionFlagType.LAYOUTS)`
- Applied consistently across page-layout, page-layout-tab, and
page-layout-widget endpoints

## Security Model
All endpoints now follow a consistent pattern:
1. **Authentication**: `UserAuthGuard`, `WorkspaceAuthGuard`, or
`PublicEndpointGuard`
2. **Authorization**: One of:
- `SettingsPermissionGuard(PermissionFlagType.XXX)` - for settings/admin
operations
- `CustomPermissionGuard` - when permissions are checked in service/data
layer
   - `NoPermissionGuard` - for public or non-sensitive read operations

The ESLint rules automatically enforce this pattern going forward.

## Stats
- 47 files changed
- 603 insertions, 163 deletions
- 3 new guard files created
This commit is contained in:
Félix Malfait
2025-11-10 12:17:38 +01:00
committed by GitHub
parent ae2d399d6e
commit 9a80164cf3
62 changed files with 727 additions and 147 deletions
@@ -15,8 +15,10 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { AgentChatService } from './agent-chat.service';
import { AgentStreamingService } from './agent-streaming.service';
@@ -31,6 +33,7 @@ export class AgentChatController {
) {}
@Post('stream')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
async streamAgentChat(
@Body()
body: {
@@ -23,7 +23,11 @@ import { AgentDTO } from './dtos/agent.dto';
import { CreateAgentInput } from './dtos/create-agent.input';
import { UpdateAgentInput } from './dtos/update-agent.input';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.AI),
)
@Resolver()
export class AgentResolver {
constructor(
@@ -17,7 +17,10 @@ import { type RemoteServerType } from 'src/engine/metadata-modules/remote-server
import { RemoteServerService } from 'src/engine/metadata-modules/remote-server/remote-server.service';
import { remoteServerGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/remote-server/utils/remote-server-graphql-api-exception-handler.util';
@UseGuards(WorkspaceAuthGuard)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.DATA_MODEL),
)
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@Resolver()
@@ -14,7 +14,10 @@ import { RemoteTableDTO } from 'src/engine/metadata-modules/remote-server/remote
import { RemoteTableService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table.service';
import { remoteTableGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/remote-server/remote-table/utils/remote-table-graphql-api-exception-handler.util';
@UseGuards(WorkspaceAuthGuard)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.DATA_MODEL),
)
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@Resolver()
@@ -12,13 +12,14 @@ import {
import { Request } from 'express';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger-rest-api-exception-filter';
import { HTTPMethod } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
import { RouteTriggerService } from 'src/engine/metadata-modules/route-trigger/route-trigger.service';
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger-rest-api-exception-filter';
@Controller('s')
@UseGuards(PublicEndpointGuard)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UseFilters(RouteTriggerRestApiExceptionFilter)
export class RouteTriggerController {
constructor(private readonly routeTriggerService: RouteTriggerService) {}
@@ -25,7 +25,11 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.WORKFLOWS),
)
@Resolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@@ -17,6 +17,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
@@ -47,6 +48,7 @@ export class ViewFieldController {
) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('viewId') viewId?: string,
@@ -59,6 +61,7 @@ export class ViewFieldController {
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -5,6 +5,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
import { DeleteViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/delete-view-field.input';
@@ -35,6 +36,7 @@ export class ViewFieldResolver {
) {}
@Query(() => [ViewFieldDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewFields(
@Args('viewId', { type: () => String }) viewId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -43,6 +45,7 @@ export class ViewFieldResolver {
}
@Query(() => ViewFieldDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async getCoreViewField(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -15,6 +15,7 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/create-view-filter-group.input';
import { UpdateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/update-view-filter-group.input';
@@ -41,6 +42,7 @@ export class ViewFilterGroupController {
) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('viewId') viewId?: string,
@@ -53,6 +55,7 @@ export class ViewFilterGroupController {
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/create-view-filter-group.input';
import { UpdateViewFilterGroupInput } from 'src/engine/metadata-modules/view-filter-group/dtos/inputs/update-view-filter-group.input';
@@ -25,6 +26,7 @@ export class ViewFilterGroupResolver {
) {}
@Query(() => [ViewFilterGroupDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewFilterGroups(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('viewId', { type: () => String, nullable: true })
@@ -38,6 +40,7 @@ export class ViewFilterGroupResolver {
}
@Query(() => ViewFilterGroupDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async getCoreViewFilterGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -17,6 +17,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input';
import { UpdateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/update-view-filter.input';
@@ -46,6 +47,7 @@ export class ViewFilterController {
) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('viewId') viewId?: string,
@@ -58,6 +60,7 @@ export class ViewFilterController {
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -5,6 +5,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input';
import { DeleteViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/delete-view-filter.input';
@@ -30,6 +31,7 @@ export class ViewFilterResolver {
) {}
@Query(() => [ViewFilterDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewFilters(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('viewId', { type: () => String, nullable: true })
@@ -43,6 +45,7 @@ export class ViewFilterResolver {
}
@Query(() => ViewFilterDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async getCoreViewFilter(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -17,6 +17,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
@@ -46,6 +47,7 @@ export class ViewGroupController {
) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('viewId') viewId?: string,
@@ -58,6 +60,7 @@ export class ViewGroupController {
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -5,6 +5,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
import { DeleteViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/delete-view-group.input';
@@ -34,6 +35,7 @@ export class ViewGroupResolver {
) {}
@Query(() => [ViewGroupDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewGroups(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('viewId', { type: () => String, nullable: true })
@@ -47,6 +49,7 @@ export class ViewGroupResolver {
}
@Query(() => ViewGroupDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async getCoreViewGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -0,0 +1,39 @@
import {
Injectable,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { ViewAccessService } from 'src/engine/metadata-modules/view-permissions/services/view-access.service';
import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum';
@Injectable()
export class CreateViewPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let visibility: ViewVisibility = ViewVisibility.WORKSPACE;
// For GraphQL: extract from args.input
const args = gqlContext.getArgs();
if (args?.input?.visibility) {
visibility = args.input.visibility as ViewVisibility;
}
// For REST: extract from request body
if (!args?.input && request.body?.visibility) {
visibility = request.body.visibility as ViewVisibility;
}
return this.viewAccessService.canUserCreateView(
visibility,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -71,6 +71,41 @@ export class ViewAccessService {
return this.checkViewAccess(view, userWorkspaceId, workspaceId);
}
async canUserCreateView(
visibility: ViewVisibility,
userWorkspaceId: string | undefined,
workspaceId: string,
): Promise<boolean> {
// For WORKSPACE visibility views, user must have VIEWS permission
if (visibility === ViewVisibility.WORKSPACE && isDefined(userWorkspaceId)) {
const permissions =
await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId,
workspaceId,
});
const hasViewsPermission =
permissions.permissionFlags[PermissionFlagType.VIEWS] ?? false;
if (!hasViewsPermission) {
throw new ViewException(
generateViewExceptionMessage(
ViewExceptionMessageKey.VIEW_CREATE_PERMISSION_DENIED,
),
ViewExceptionCode.VIEW_CREATE_PERMISSION_DENIED,
{
userFriendlyMessage: generateViewUserFriendlyExceptionMessage(
ViewExceptionMessageKey.VIEW_CREATE_PERMISSION_DENIED,
),
},
);
}
}
// For UNLISTED views or users without userWorkspaceId, allow creation
return true;
}
private async checkViewAccess(
view: ViewEntity,
userWorkspaceId: string | undefined,
@@ -11,6 +11,7 @@ import { CreateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view
import { CreateViewFilterGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-filter-group-permission.guard';
import { CreateViewFilterPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-filter-permission.guard';
import { CreateViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-group-permission.guard';
import { CreateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard';
import { CreateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-sort-permission.guard';
import { DeleteViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-field-permission.guard';
import { DeleteViewFilterGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-filter-group-permission.guard';
@@ -55,6 +56,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ViewService,
ViewEntityLookupService,
ViewAccessService,
CreateViewPermissionGuard,
UpdateViewPermissionGuard,
DeleteViewPermissionGuard,
DestroyViewPermissionGuard,
@@ -83,6 +85,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ViewService,
ViewEntityLookupService,
ViewAccessService,
CreateViewPermissionGuard,
UpdateViewPermissionGuard,
DeleteViewPermissionGuard,
DestroyViewPermissionGuard,
@@ -15,6 +15,7 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-sort-permission.guard';
import { DeleteViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-sort-permission.guard';
@@ -39,6 +40,7 @@ export class ViewSortController {
constructor(private readonly viewSortService: ViewSortService) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('viewId') viewId?: string,
@@ -51,6 +53,7 @@ export class ViewSortController {
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-sort-permission.guard';
import { DeleteViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-sort-permission.guard';
@@ -23,6 +24,7 @@ export class ViewSortResolver {
constructor(private readonly viewSortService: ViewSortService) {}
@Query(() => [ViewSortDTO])
@UseGuards(NoPermissionGuard)
async getCoreViewSorts(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('viewId', { type: () => String, nullable: true })
@@ -36,6 +38,7 @@ export class ViewSortResolver {
}
@Query(() => ViewSortDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async getCoreViewSort(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -21,8 +21,11 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequestLocale } from 'src/engine/decorators/locale/request-locale.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { CreateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard';
import { DeleteViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-permission.guard';
import { UpdateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-permission.guard';
import { CreateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/create-view.input';
@@ -53,6 +56,7 @@ export class ViewController {
) {}
@Get()
@UseGuards(CustomPermissionGuard)
async findMany(
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -71,6 +75,7 @@ export class ViewController {
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
@@ -103,6 +108,7 @@ export class ViewController {
}
@Post()
@UseGuards(CreateViewPermissionGuard)
async create(
@Body() input: CreateViewInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -144,6 +150,7 @@ export class ViewController {
@Body() input: UpdateViewInput,
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
): Promise<ViewDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
@@ -160,9 +167,15 @@ export class ViewController {
id,
},
workspaceId: workspace.id,
userWorkspaceId,
});
} else {
updatedView = await this.viewService.update(id, workspace.id, input);
updatedView = await this.viewService.update(
id,
workspace.id,
input,
userWorkspaceId,
);
}
const processedViews = await this.processViewsWithTemplates(
@@ -21,10 +21,9 @@ import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
@@ -34,6 +33,7 @@ import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { CreateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard';
import { DeleteViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-permission.guard';
import { DestroyViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-permission.guard';
import { UpdateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-permission.guard';
@@ -44,13 +44,6 @@ import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/up
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum';
import {
ViewException,
ViewExceptionCode,
ViewExceptionMessageKey,
generateViewExceptionMessage,
generateViewUserFriendlyExceptionMessage,
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { ViewV2Service } from 'src/engine/metadata-modules/view/services/view-v2.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
@@ -70,7 +63,6 @@ export class ViewResolver {
private readonly featureFlagService: FeatureFlagService,
private readonly viewV2Service: ViewV2Service,
private readonly userRoleService: UserRoleService,
private readonly permissionsService: PermissionsService,
) {}
@ResolveField(() => String)
@@ -119,6 +111,7 @@ export class ViewResolver {
}
@Query(() => [ViewDTO])
@UseGuards(CustomPermissionGuard)
async getCoreViews(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@@ -137,6 +130,7 @@ export class ViewResolver {
}
@Query(() => ViewDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async getCoreView(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -152,7 +146,7 @@ export class ViewResolver {
}
@Mutation(() => ViewDTO)
@UseGuards(CustomPermissionGuard)
@UseGuards(CreateViewPermissionGuard)
async createCoreView(
@Args('input') input: CreateViewInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -160,28 +154,6 @@ export class ViewResolver {
): Promise<ViewDTO> {
const visibility = input.visibility ?? ViewVisibility.WORKSPACE;
if (visibility === ViewVisibility.WORKSPACE && isDefined(userWorkspaceId)) {
const permissions =
await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId,
workspaceId: workspace.id,
});
if (!permissions.permissionFlags[PermissionFlagType.VIEWS]) {
throw new ViewException(
generateViewExceptionMessage(
ViewExceptionMessageKey.VIEW_CREATE_PERMISSION_DENIED,
),
ViewExceptionCode.VIEW_CREATE_PERMISSION_DENIED,
{
userFriendlyMessage: generateViewUserFriendlyExceptionMessage(
ViewExceptionMessageKey.VIEW_CREATE_PERMISSION_DENIED,
),
},
);
}
}
input.visibility = visibility;
const isWorkspaceMigrationV2Enabled =
@@ -213,6 +185,7 @@ export class ViewResolver {
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdateViewInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
): Promise<ViewDTO> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
@@ -224,10 +197,11 @@ export class ViewResolver {
return await this.viewV2Service.updateOne({
updateViewInput: { ...input, id },
workspaceId: workspace.id,
userWorkspaceId,
});
}
return this.viewService.update(id, workspace.id, input);
return this.viewService.update(id, workspace.id, input, userWorkspaceId);
}
@Mutation(() => Boolean)
@@ -382,6 +382,93 @@ describe('ViewService', () => {
),
);
});
it('should re-allocate view to current user when changing from WORKSPACE to UNLISTED visibility', async () => {
const id = 'view-id';
const workspaceId = 'workspace-id';
const userWorkspaceId = 'current-user-workspace-id';
const workspaceView = {
...mockView,
visibility: ViewVisibility.WORKSPACE,
createdByUserWorkspaceId: null,
} as ViewEntity;
const updateData = { visibility: ViewVisibility.UNLISTED };
const expectedSaveData = {
id,
visibility: ViewVisibility.UNLISTED,
createdByUserWorkspaceId: userWorkspaceId,
};
const updatedView = {
...workspaceView,
...expectedSaveData,
};
jest.spyOn(viewService, 'findById').mockResolvedValue(workspaceView);
jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView);
const result = await viewService.update(
id,
workspaceId,
updateData,
userWorkspaceId,
);
expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId);
expect(viewRepository.save).toHaveBeenCalledWith(expectedSaveData);
expect(result.createdByUserWorkspaceId).toBe(userWorkspaceId);
});
it('should not change createdByUserWorkspaceId when visibility is not changing to UNLISTED', async () => {
const id = 'view-id';
const workspaceId = 'workspace-id';
const userWorkspaceId = 'current-user-workspace-id';
const updateData = { name: 'Updated Name' };
const updatedView = { ...mockView, ...updateData };
jest.spyOn(viewService, 'findById').mockResolvedValue(mockView);
jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView);
await viewService.update(id, workspaceId, updateData, userWorkspaceId);
expect(viewRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(viewRepository.save).not.toHaveBeenCalledWith(
expect.objectContaining({
createdByUserWorkspaceId: userWorkspaceId,
}),
);
});
it('should not change createdByUserWorkspaceId when view is already UNLISTED', async () => {
const id = 'view-id';
const workspaceId = 'workspace-id';
const userWorkspaceId = 'current-user-workspace-id';
const originalOwner = 'original-owner-workspace-id';
const unlistedView = {
...mockView,
visibility: ViewVisibility.UNLISTED,
createdByUserWorkspaceId: originalOwner,
} as ViewEntity;
const updateData = { visibility: ViewVisibility.UNLISTED };
const updatedView = { ...unlistedView, ...updateData };
jest.spyOn(viewService, 'findById').mockResolvedValue(unlistedView);
jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView);
await viewService.update(id, workspaceId, updateData, userWorkspaceId);
expect(viewRepository.save).toHaveBeenCalledWith({
id,
...updateData,
});
expect(viewRepository.save).not.toHaveBeenCalledWith(
expect.objectContaining({
createdByUserWorkspaceId: userWorkspaceId,
}),
);
});
});
describe('delete', () => {
@@ -100,9 +100,11 @@ export class ViewV2Service {
async updateOne({
updateViewInput,
workspaceId,
userWorkspaceId,
}: {
updateViewInput: UpdateViewInput;
workspaceId: string;
userWorkspaceId?: string;
}): Promise<ViewDTO> {
const {
flatViewMaps: existingFlatViewMaps,
@@ -121,6 +123,21 @@ export class ViewV2Service {
flatViewMaps: existingFlatViewMaps,
});
const existingFlatView = existingFlatViewMaps.byId[updateViewInput.id];
// If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set
// This prevents the view from disappearing for the user making the change
if (
isDefined(existingFlatView) &&
isDefined(updateViewInput.visibility) &&
updateViewInput.visibility === 'UNLISTED' &&
existingFlatView.visibility === 'WORKSPACE' &&
isDefined(userWorkspaceId)
) {
// Re-allocate the view to the current user
flatViewFromUpdateInput.createdByUserWorkspaceId = userWorkspaceId;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -193,6 +193,7 @@ export class ViewService {
id: string,
workspaceId: string,
updateData: Partial<ViewEntity>,
userWorkspaceId?: string,
): Promise<ViewEntity> {
const existingView = await this.findById(id, workspaceId);
@@ -206,9 +207,23 @@ export class ViewService {
);
}
// If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set
// This prevents the view from disappearing for the user making the change
const dataToUpdate = { ...updateData };
if (
isDefined(updateData.visibility) &&
updateData.visibility === ViewVisibility.UNLISTED &&
existingView.visibility === ViewVisibility.WORKSPACE &&
isDefined(userWorkspaceId)
) {
// Re-allocate the view to the current user if it has no owner or a different owner
dataToUpdate.createdByUserWorkspaceId = userWorkspaceId;
}
const updatedView = await this.viewRepository.save({
id,
...updateData,
...dataToUpdate,
});
await this.flushGraphQLCache(workspaceId);