From 9a80164cf33844e5b161a42087236d93545bcaa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 10 Nov 2025 12:17:38 +0100 Subject: [PATCH] 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 --- .../controllers/mcp-metadata.controller.ts | 11 +- .../controllers/rest-api-core.controller.ts | 3 +- .../metadata/rest-api-metadata.controller.ts | 14 +- .../src/engine/api/rest/rest-api.module.ts | 2 + .../ai/controllers/ai.controller.ts | 3 +- .../ai/controllers/mcp.controller.ts | 15 +- .../api-key/controllers/api-key.controller.ts | 8 +- .../engine/core-modules/auth/auth.resolver.ts | 8 +- .../google-apis-auth.controller.ts | 15 +- .../controllers/google-auth.controller.ts | 15 +- .../microsoft-apis-auth.controller.ts | 13 +- .../controllers/microsoft-auth.controller.ts | 3 + .../auth/controllers/sso-auth.controller.ts | 43 ++++- .../billing-webhook.controller.ts | 5 +- .../core-modules/billing/billing.resolver.ts | 2 +- .../timeline-calendar-event.resolver.ts | 3 +- .../client-config/client-config.controller.ts | 3 +- .../controllers/dns-cloudflare.controller.ts | 7 +- .../file/controllers/file.controller.ts | 3 +- .../geo-map/resolver/geo-map.resolver.ts | 3 +- .../health/controllers/health.controller.ts | 5 +- .../messaging/timeline-messaging.resolver.ts | 3 +- .../open-api/open-api.controller.ts | 5 +- .../controllers/page-layout-tab.controller.ts | 8 + .../page-layout-widget.controller.ts | 8 + .../controllers/page-layout.controller.ts | 8 + .../resolvers/page-layout-tab.resolver.ts | 3 + .../resolvers/page-layout-widget.resolver.ts | 3 + .../resolvers/page-layout.resolver.ts | 14 +- .../core-modules/search/search.resolver.ts | 3 +- .../webhook/controllers/webhook.controller.ts | 8 +- .../workflow-trigger.controller.ts | 5 +- .../workspace/workspace.resolver.ts | 4 +- .../agent/agent-chat.controller.ts | 3 + .../metadata-modules/agent/agent.resolver.ts | 6 +- .../remote-server/remote-server.resolver.ts | 5 +- .../remote-table/remote-table.resolver.ts | 5 +- .../route-trigger/route-trigger.controller.ts | 5 +- .../serverless-function.resolver.ts | 6 +- .../controllers/view-field.controller.ts | 3 + .../resolvers/view-field.resolver.ts | 3 + .../view-filter-group.controller.ts | 3 + .../resolvers/view-filter-group.resolver.ts | 3 + .../controllers/view-filter.controller.ts | 3 + .../resolvers/view-filter.resolver.ts | 3 + .../controllers/view-group.controller.ts | 3 + .../resolvers/view-group.resolver.ts | 3 + .../guards/create-view-permission.guard.ts | 39 ++++ .../services/view-access.service.ts | 35 ++++ .../view-permissions.module.ts | 3 + .../controllers/view-sort.controller.ts | 3 + .../view-sort/resolvers/view-sort.resolver.ts | 3 + .../view/controllers/view.controller.ts | 15 +- .../view/resolvers/view.resolver.ts | 42 +--- .../view/services/tests/view.service.spec.ts | 87 +++++++++ .../view/services/view-v2.service.ts | 17 ++ .../view/services/view.service.ts | 17 +- .../subscriptions/subscriptions.resolver.ts | 3 +- ...raphql-resolvers-should-be-guarded.spec.ts | 81 ++++++-- .../graphql-resolvers-should-be-guarded.ts | 13 +- ...rest-api-methods-should-be-guarded.spec.ts | 182 +++++++++++++++++- .../rest-api-methods-should-be-guarded.ts | 24 ++- 62 files changed, 727 insertions(+), 147 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard.ts diff --git a/packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts b/packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts index a8770e3fb4..5fa840cc41 100644 --- a/packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts +++ b/packages/twenty-server/src/engine/api/mcp/controllers/mcp-metadata.controller.ts @@ -2,14 +2,15 @@ import { Controller, Post, Req, UseGuards } from '@nestjs/common'; import { Request } from 'express'; -import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; -import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { MCPMetadataService } from 'src/engine/api/mcp/services/mcp-metadata.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Controller('mcp/metadata') -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard) export class McpMetadataController { constructor(private readonly mCPMetadataService: MCPMetadataService) {} diff --git a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts index 2a916e2450..0f3c0828b2 100644 --- a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts +++ b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts @@ -17,11 +17,12 @@ import { Response } from 'express'; import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-core.service'; import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Controller('rest') -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, CustomPermissionGuard) @UseFilters(RestApiExceptionFilter) export class RestApiCoreController { private readonly logger = new Logger(RestApiCoreController.name); diff --git a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts index 2e4accd64a..b78768df93 100644 --- a/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts +++ b/packages/twenty-server/src/engine/api/rest/metadata/rest-api-metadata.controller.ts @@ -1,12 +1,12 @@ import { Controller, - Get, Delete, + Get, + Patch, Post, + Put, Req, Res, - Patch, - Put, UseGuards, } from '@nestjs/common'; @@ -15,10 +15,16 @@ import { Request, Response } from 'express'; import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service'; import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-response.utils'; 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 { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @Controller('rest/metadata/*') -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseGuards( + JwtAuthGuard, + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.DATA_MODEL), +) export class RestApiMetadataController { constructor( private readonly restApiMetadataService: RestApiMetadataService, diff --git a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts index 288abbe4e7..da85d9f834 100644 --- a/packages/twenty-server/src/engine/api/rest/rest-api.module.ts +++ b/packages/twenty-server/src/engine/api/rest/rest-api.module.ts @@ -8,6 +8,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ import { RestApiCoreModule } from 'src/engine/api/rest/core/rest-api-core.module'; import { RestApiService } from 'src/engine/api/rest/rest-api.service'; import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api AuthModule, HttpModule, RestApiCoreModule, + PermissionsModule, ], controllers: [RestApiMetadataController], providers: [RestApiService, RestApiMetadataService], diff --git a/packages/twenty-server/src/engine/core-modules/ai/controllers/ai.controller.ts b/packages/twenty-server/src/engine/core-modules/ai/controllers/ai.controller.ts index 4c7aa32ae2..9ef2f107b0 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/controllers/ai.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/controllers/ai.controller.ts @@ -18,6 +18,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 { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; interface ChatRequest { @@ -27,7 +28,7 @@ interface ChatRequest { } @Controller('chat') -@UseGuards(WorkspaceAuthGuard) +@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) export class AiController { constructor( private readonly aiService: AiService, diff --git a/packages/twenty-server/src/engine/core-modules/ai/controllers/mcp.controller.ts b/packages/twenty-server/src/engine/core-modules/ai/controllers/mcp.controller.ts index 479393e284..4775fc1ee1 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/controllers/mcp.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/controllers/mcp.controller.ts @@ -8,18 +8,19 @@ import { ValidationPipe, } from '@nestjs/common'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator'; -import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; +import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter'; import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc'; import { McpService } from 'src/engine/core-modules/ai/services/mcp.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator'; +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 { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Controller('mcp') -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard) @UseFilters(RestApiExceptionFilter) export class McpController { constructor(private readonly mcpService: McpService) {} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts b/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts index af51187d96..436c612992 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/controllers/api-key.controller.ts @@ -20,14 +20,20 @@ import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-a import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; 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 { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; /** * rest/apiKeys is deprecated, use rest/metadata/apiKeys instead * rest/apiKeys will be removed in the future */ @Controller(['rest/apiKeys', 'rest/metadata/apiKeys']) -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseGuards( + JwtAuthGuard, + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS), +) @UseFilters(RestApiExceptionFilter) export class ApiKeyController { constructor(private readonly apiKeyService: ApiKeyService) {} diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index bf69379da6..83b6226cf5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -125,7 +125,7 @@ export class AuthResolver { private readonly permissionsService: PermissionsService, ) {} - @UseGuards(CaptchaGuard, PublicEndpointGuard) + @UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard) @Query(() => CheckUserExistOutput) async checkUserExists( @Args() checkUserExistsInput: EmailAndCaptchaInput, @@ -147,7 +147,7 @@ export class AuthResolver { } @Query(() => WorkspaceInviteHashValidOutput) - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async checkWorkspaceInviteHashIsValid( @Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput, ): Promise { @@ -157,7 +157,7 @@ export class AuthResolver { } @Query(() => WorkspaceEntity) - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async findWorkspaceFromInviteHash( @Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput, ): Promise { @@ -833,7 +833,7 @@ export class AuthResolver { } @Query(() => ValidatePasswordResetTokenOutput) - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async validatePasswordResetToken( @Args() args: ValidatePasswordResetTokenInput, ): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts index ea69b98e98..9ece4d16c3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts @@ -23,12 +23,13 @@ import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/gu import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; +import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; -import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; @Controller('auth/google-apis') @UseFilters(AuthRestApiExceptionFilter) @@ -45,14 +46,22 @@ export class GoogleAPIsAuthController { ) {} @Get() - @UseGuards(GoogleAPIsOauthRequestCodeGuard, PublicEndpointGuard) + @UseGuards( + GoogleAPIsOauthRequestCodeGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async googleAuth() { // As this method is protected by Google Auth guard, it will trigger Google SSO flow return; } @Get('get-access-token') - @UseGuards(GoogleAPIsOauthExchangeCodeForTokenGuard, PublicEndpointGuard) + @UseGuards( + GoogleAPIsOauthExchangeCodeForTokenGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async googleAuthGetAccessToken( @Req() req: GoogleAPIsRequest, @Res() res: Response, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts index 9b1115197b..d4d41bf41f 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-auth.controller.ts @@ -16,6 +16,7 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('auth/google') @@ -24,14 +25,24 @@ export class GoogleAuthController { constructor(private readonly authService: AuthService) {} @Get() - @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard) + @UseGuards( + GoogleProviderEnabledGuard, + GoogleOauthGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async googleAuth() { // As this method is protected by Google Auth guard, it will trigger Google SSO flow return; } @Get('redirect') - @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard) + @UseGuards( + GoogleProviderEnabledGuard, + GoogleOauthGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) @UseFilters(AuthOAuthExceptionFilter) async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { return res.redirect( diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts index f6a7c94ec9..2a30b41189 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts @@ -28,6 +28,7 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('auth/microsoft-apis') @@ -45,14 +46,22 @@ export class MicrosoftAPIsAuthController { ) {} @Get() - @UseGuards(MicrosoftAPIsOauthRequestCodeGuard, PublicEndpointGuard) + @UseGuards( + MicrosoftAPIsOauthRequestCodeGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async MicrosoftAuth() { // As this method is protected by Microsoft Auth guard, it will trigger Microsoft SSO flow return; } @Get('get-access-token') - @UseGuards(MicrosoftAPIsOauthExchangeCodeForTokenGuard, PublicEndpointGuard) + @UseGuards( + MicrosoftAPIsOauthExchangeCodeForTokenGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async MicrosoftAuthGetAccessToken( @Req() req: MicrosoftAPIsRequest, @Res() res: Response, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts index b663823f51..59f9526f31 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-auth.controller.ts @@ -15,6 +15,7 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('auth/microsoft') @@ -27,6 +28,7 @@ export class MicrosoftAuthController { MicrosoftProviderEnabledGuard, MicrosoftOAuthGuard, PublicEndpointGuard, + NoPermissionGuard, ) async microsoftAuth() { // As this method is protected by Microsoft Auth guard, it will trigger Microsoft SSO flow @@ -38,6 +40,7 @@ export class MicrosoftAuthController { MicrosoftProviderEnabledGuard, MicrosoftOAuthGuard, PublicEndpointGuard, + NoPermissionGuard, ) async microsoftAuthRedirect( @Req() req: MicrosoftRequest, diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts index bd2794a02b..b104c28067 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-auth.controller.ts @@ -21,10 +21,6 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { - WorkspaceSSOIdentityProviderEntity, - IdentityProviderType, -} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard'; import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.guard'; @@ -36,9 +32,14 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { + IdentityProviderType, + WorkspaceSSOIdentityProviderEntity, +} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('auth') @@ -56,7 +57,11 @@ export class SSOAuthController { ) {} @Get('saml/metadata/:identityProviderId') - @UseGuards(EnterpriseFeaturesEnabledGuard, PublicEndpointGuard) + @UseGuards( + EnterpriseFeaturesEnabledGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) // eslint-disable-next-line @typescript-eslint/no-explicit-any async generateMetadata(@Req() req: any): Promise { return generateServiceProviderMetadata({ @@ -73,27 +78,47 @@ export class SSOAuthController { } @Get('oidc/login/:identityProviderId') - @UseGuards(EnterpriseFeaturesEnabledGuard, OIDCAuthGuard, PublicEndpointGuard) + @UseGuards( + EnterpriseFeaturesEnabledGuard, + OIDCAuthGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async oidcAuth() { // As this method is protected by OIDC Auth guard, it will trigger OIDC SSO flow return; } @Get('saml/login/:identityProviderId') - @UseGuards(EnterpriseFeaturesEnabledGuard, SAMLAuthGuard, PublicEndpointGuard) + @UseGuards( + EnterpriseFeaturesEnabledGuard, + SAMLAuthGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async samlAuth() { // As this method is protected by SAML Auth guard, it will trigger SAML SSO flow return; } @Get('oidc/callback') - @UseGuards(EnterpriseFeaturesEnabledGuard, OIDCAuthGuard, PublicEndpointGuard) + @UseGuards( + EnterpriseFeaturesEnabledGuard, + OIDCAuthGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async oidcAuthCallback(@Req() req: OIDCRequest, @Res() res: Response) { return await this.authCallback(req, res); } @Post('saml/callback/:identityProviderId') - @UseGuards(EnterpriseFeaturesEnabledGuard, SAMLAuthGuard, PublicEndpointGuard) + @UseGuards( + EnterpriseFeaturesEnabledGuard, + SAMLAuthGuard, + PublicEndpointGuard, + NoPermissionGuard, + ) async samlAuthCallback(@Req() req: SAMLRequest, @Res() res: Response) { try { return await this.authCallback(req, res); diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts index dfb6ccff21..12b2811900 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.controller.ts @@ -21,8 +21,8 @@ import { BillingWebhookEntitlementService } from 'src/engine/core-modules/billin import { BillingWebhookInvoiceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service'; import { BillingWebhookPriceService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-price.service'; import { BillingWebhookProductService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-product.service'; -import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service'; import { BillingWebhookSubscriptionScheduleService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service'; +import { BillingWebhookSubscriptionService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service'; import { BillingException, BillingExceptionCode, @@ -31,6 +31,7 @@ import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billi import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller() @@ -52,7 +53,7 @@ export class BillingWebhookController { ) {} @Post(['webhooks/stripe']) - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async handleWebhooks( @Headers('stripe-signature') signature: string, @Req() req: RawBodyRequest, diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 0cbd0cf674..efe81ce75b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -246,7 +246,7 @@ export class BillingResolver { } @Query(() => [BillingPlanOutput]) - @UseGuards(WorkspaceAuthGuard) + @UseGuards(WorkspaceAuthGuard, NoPermissionGuard) async listPlans(): Promise { const plans = await this.billingPlanService.listPlans(); diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts index 9e3fb69e91..a260a589bc 100644 --- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts @@ -8,6 +8,7 @@ import { TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE } from 'src/engine/core-modules/ import { TimelineCalendarEventsWithTotalDTO } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-events-with-total.dto'; import { TimelineCalendarEventService } from 'src/engine/core-modules/calendar/timeline-calendar-event.service'; import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator'; +import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @ArgsType() @@ -49,7 +50,7 @@ class GetTimelineCalendarEventsFromOpportunityIdArgs { pageSize: number; } -@UseGuards(WorkspaceAuthGuard) +@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) @Resolver(() => TimelineCalendarEventsWithTotalDTO) export class TimelineCalendarEventResolver { constructor( diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts index accbdf7649..e790b59a67 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import { type ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity'; import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('/client-config') @@ -9,7 +10,7 @@ export class ClientConfigController { constructor(private readonly clientConfigService: ClientConfigService) {} @Get() - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async getClientConfig(): Promise { return this.clientConfigService.getClientConfig(); } diff --git a/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts b/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts index 44c972a69b..b572ac2129 100644 --- a/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller.ts @@ -5,11 +5,12 @@ import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common'; import { Request } from 'express'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter'; -import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; -import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter'; import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard'; import { DnsCloudflareService } from 'src/engine/core-modules/cloudflare/services/dns-cloudflare.service'; +import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller() @UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter) @@ -20,7 +21,7 @@ export class DnsCloudflareController { ) {} @Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare']) - @UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard) + @UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard, NoPermissionGuard) async customHostnameWebhooks(@Req() req: Request) { const hostname = req.body?.data?.data?.hostname; diff --git a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts index 4f09dfc45c..ba3f59a254 100644 --- a/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts @@ -23,6 +23,7 @@ import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/fil import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('files') @@ -32,7 +33,7 @@ export class FileController { constructor(private readonly fileService: FileService) {} @Get('*/:filename') - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async getFile( @Param() _params: string[], @Res() res: Response, diff --git a/packages/twenty-server/src/engine/core-modules/geo-map/resolver/geo-map.resolver.ts b/packages/twenty-server/src/engine/core-modules/geo-map/resolver/geo-map.resolver.ts index 77ffc0df58..3b0823315b 100644 --- a/packages/twenty-server/src/engine/core-modules/geo-map/resolver/geo-map.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/geo-map/resolver/geo-map.resolver.ts @@ -4,10 +4,11 @@ import { Args, Query, Resolver } from '@nestjs/graphql'; import { AutocompleteResultDTO } from 'src/engine/core-modules/geo-map/dtos/autocomplete-result.dto'; import { PlaceDetailsResultDTO } from 'src/engine/core-modules/geo-map/dtos/place-details-result.dto'; import { GeoMapService } from 'src/engine/core-modules/geo-map/services/geo-map.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Resolver() -@UseGuards(WorkspaceAuthGuard) +@UseGuards(WorkspaceAuthGuard, NoPermissionGuard) export class GeoMapResolver { constructor(private readonly geoMapService: GeoMapService) {} diff --git a/packages/twenty-server/src/engine/core-modules/health/controllers/health.controller.ts b/packages/twenty-server/src/engine/core-modules/health/controllers/health.controller.ts index 572651ee01..e0ca471e5e 100644 --- a/packages/twenty-server/src/engine/core-modules/health/controllers/health.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/health/controllers/health.controller.ts @@ -13,6 +13,7 @@ import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicator import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health'; import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health'; import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller('healthz') @@ -27,14 +28,14 @@ export class HealthController { ) {} @Get() - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) @HealthCheck() check() { return this.health.check([]); } @Get(':indicatorId') - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) @HealthCheck() checkService(@Param('indicatorId') indicatorId: HealthIndicatorId) { const checks = { diff --git a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts index 31033ca58e..08a22d8ac6 100644 --- a/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/messaging/timeline-messaging.resolver.ts @@ -12,6 +12,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @@ -54,7 +55,7 @@ class GetTimelineThreadsFromOpportunityIdArgs { pageSize: number; } -@UseGuards(WorkspaceAuthGuard, UserAuthGuard) +@UseGuards(WorkspaceAuthGuard, UserAuthGuard, CustomPermissionGuard) @Resolver(() => TimelineThreadsWithTotalDTO) export class TimelineMessagingResolver { constructor( diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.controller.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.controller.ts index bfeefd03cb..dc262d60a0 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; import { Request, Response } from 'express'; import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; @Controller() @@ -10,7 +11,7 @@ export class OpenApiController { constructor(private readonly openApiService: OpenApiService) {} @Get(['open-api/core', 'rest/open-api/core']) - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async generateOpenApiSchemaCore( @Req() request: Request, @Res() res: Response, @@ -21,7 +22,7 @@ export class OpenApiController { } @Get(['open-api/metadata', 'rest/open-api/metadata']) - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async generateOpenApiSchemaMetaData( @Req() request: Request, @Res() res: Response, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts index 4aa5434d31..4292280b0b 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-tab.controller.ts @@ -26,7 +26,10 @@ import { PageLayoutTabRestApiExceptionFilter } from 'src/engine/core-modules/pag import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @Controller('rest/metadata/pageLayoutTabs') @UseGuards(WorkspaceAuthGuard) @@ -35,6 +38,7 @@ export class PageLayoutTabController { constructor(private readonly pageLayoutTabService: PageLayoutTabService) {} @Get() + @UseGuards(NoPermissionGuard) async findMany( @AuthWorkspace() workspace: WorkspaceEntity, @Query('pageLayoutId') pageLayoutId: string, @@ -55,6 +59,7 @@ export class PageLayoutTabController { } @Get(':id') + @UseGuards(NoPermissionGuard) async findOne( @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -63,6 +68,7 @@ export class PageLayoutTabController { } @Post() + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async create( @Body() input: CreatePageLayoutTabInput, @AuthWorkspace() workspace: WorkspaceEntity, @@ -71,6 +77,7 @@ export class PageLayoutTabController { } @Patch(':id') + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async update( @Param('id') id: string, @Body() input: UpdatePageLayoutTabInput, @@ -80,6 +87,7 @@ export class PageLayoutTabController { } @Delete(':id') + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async delete( @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-widget.controller.ts b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-widget.controller.ts index 9a5eaa3035..de8d10b7e3 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-widget.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout-widget.controller.ts @@ -26,7 +26,10 @@ import { PageLayoutWidgetRestApiExceptionFilter } from 'src/engine/core-modules/ import { PageLayoutWidgetService } from 'src/engine/core-modules/page-layout/services/page-layout-widget.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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @Controller('rest/metadata/pageLayoutWidgets') @UseGuards(WorkspaceAuthGuard) @@ -37,6 +40,7 @@ export class PageLayoutWidgetController { ) {} @Get() + @UseGuards(NoPermissionGuard) async findMany( @AuthWorkspace() workspace: WorkspaceEntity, @Query('pageLayoutTabId') pageLayoutTabId: string, @@ -57,6 +61,7 @@ export class PageLayoutWidgetController { } @Get(':id') + @UseGuards(NoPermissionGuard) async findOne( @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -65,6 +70,7 @@ export class PageLayoutWidgetController { } @Post() + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async create( @Body() input: CreatePageLayoutWidgetInput, @AuthWorkspace() workspace: WorkspaceEntity, @@ -73,6 +79,7 @@ export class PageLayoutWidgetController { } @Patch(':id') + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async update( @Param('id') id: string, @Body() input: UpdatePageLayoutWidgetInput, @@ -82,6 +89,7 @@ export class PageLayoutWidgetController { } @Delete(':id') + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async delete( @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout.controller.ts b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout.controller.ts index 9baab3cb0c..890c056337 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/controllers/page-layout.controller.ts @@ -21,7 +21,10 @@ import { PageLayoutRestApiExceptionFilter } from 'src/engine/core-modules/page-l import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @Controller('rest/metadata/pageLayouts') @UseGuards(WorkspaceAuthGuard) @@ -30,6 +33,7 @@ export class PageLayoutController { constructor(private readonly pageLayoutService: PageLayoutService) {} @Get() + @UseGuards(NoPermissionGuard) async findMany( @AuthWorkspace() workspace: WorkspaceEntity, @Query('objectMetadataId') objectMetadataId?: string, @@ -45,6 +49,7 @@ export class PageLayoutController { } @Get(':id') + @UseGuards(NoPermissionGuard) async findOne( @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -53,6 +58,7 @@ export class PageLayoutController { } @Post() + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async create( @Body() input: CreatePageLayoutInput, @AuthWorkspace() workspace: WorkspaceEntity, @@ -61,6 +67,7 @@ export class PageLayoutController { } @Patch(':id') + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async update( @Param('id') id: string, @Body() input: UpdatePageLayoutInput, @@ -76,6 +83,7 @@ export class PageLayoutController { } @Delete(':id') + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async delete( @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts index c6b67d12b3..bc95a8779f 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts @@ -11,6 +11,7 @@ import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/servic import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter'; import { type 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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @@ -23,6 +24,7 @@ export class PageLayoutTabResolver { constructor(private readonly pageLayoutTabService: PageLayoutTabService) {} @Query(() => [PageLayoutTabDTO]) + @UseGuards(NoPermissionGuard) async getPageLayoutTabs( @AuthWorkspace() workspace: WorkspaceEntity, @Args('pageLayoutId', { type: () => String }) pageLayoutId: string, @@ -34,6 +36,7 @@ export class PageLayoutTabResolver { } @Query(() => PageLayoutTabDTO) + @UseGuards(NoPermissionGuard) async getPageLayoutTab( @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts index 5c67fb5ecb..f19d32651e 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts @@ -18,6 +18,7 @@ import { injectWidgetConfigurationDiscriminator } from 'src/engine/core-modules/ import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter'; import { type 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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @@ -32,6 +33,7 @@ export class PageLayoutWidgetResolver { ) {} @Query(() => [PageLayoutWidgetDTO]) + @UseGuards(NoPermissionGuard) async getPageLayoutWidgets( @AuthWorkspace() workspace: WorkspaceEntity, @Args('pageLayoutTabId', { type: () => String }) pageLayoutTabId: string, @@ -43,6 +45,7 @@ export class PageLayoutWidgetResolver { } @Query(() => PageLayoutWidgetDTO) + @UseGuards(NoPermissionGuard) async getPageLayoutWidget( @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts index 636d3d298d..58865baf22 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts @@ -13,16 +13,14 @@ import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/ import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter'; 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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @Resolver(() => PageLayoutDTO) @UseFilters(PageLayoutGraphqlApiExceptionFilter) -@UseGuards( - WorkspaceAuthGuard, - SettingsPermissionGuard(PermissionFlagType.LAYOUTS), -) +@UseGuards(WorkspaceAuthGuard) @UsePipes(ResolverValidationPipe) export class PageLayoutResolver { constructor( @@ -31,6 +29,7 @@ export class PageLayoutResolver { ) {} @Query(() => [PageLayoutDTO]) + @UseGuards(NoPermissionGuard) async getPageLayouts( @AuthWorkspace() workspace: WorkspaceEntity, @Args('objectMetadataId', { type: () => String, nullable: true }) @@ -47,6 +46,7 @@ export class PageLayoutResolver { } @Query(() => PageLayoutDTO, { nullable: true }) + @UseGuards(NoPermissionGuard) async getPageLayout( @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -55,6 +55,7 @@ export class PageLayoutResolver { } @Mutation(() => PageLayoutDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async createPageLayout( @Args('input') input: CreatePageLayoutInput, @AuthWorkspace() workspace: WorkspaceEntity, @@ -63,6 +64,7 @@ export class PageLayoutResolver { } @Mutation(() => PageLayoutDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async updatePageLayout( @Args('id', { type: () => String }) id: string, @Args('input') input: UpdatePageLayoutInput, @@ -72,6 +74,7 @@ export class PageLayoutResolver { } @Mutation(() => PageLayoutDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async deletePageLayout( @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -85,6 +88,7 @@ export class PageLayoutResolver { } @Mutation(() => Boolean) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async destroyPageLayout( @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -98,6 +102,7 @@ export class PageLayoutResolver { } @Mutation(() => PageLayoutDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async restorePageLayout( @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, @@ -106,6 +111,7 @@ export class PageLayoutResolver { } @Mutation(() => PageLayoutDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS)) async updatePageLayoutWithTabsAndWidgets( @Args('id', { type: () => String }) id: string, @Args('input') input: UpdatePageLayoutWithTabsInput, diff --git a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts index 399791deef..b9ed43df21 100644 --- a/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/search/search.resolver.ts @@ -11,12 +11,14 @@ import { SearchApiExceptionFilter } from 'src/engine/core-modules/search/filters import { SearchService } from 'src/engine/core-modules/search/services/search.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; @Resolver() @UseFilters(SearchApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter) @UsePipes(ResolverValidationPipe) +@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) export class SearchResolver { constructor( private readonly searchService: SearchService, @@ -24,7 +26,6 @@ export class SearchResolver { ) {} @Query(() => SearchResultConnectionDTO) - @UseGuards(WorkspaceAuthGuard) async search( @AuthWorkspace() workspace: WorkspaceEntity, @Args() diff --git a/packages/twenty-server/src/engine/core-modules/webhook/controllers/webhook.controller.ts b/packages/twenty-server/src/engine/core-modules/webhook/controllers/webhook.controller.ts index 24261fdc72..5c33273209 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/controllers/webhook.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/webhook/controllers/webhook.controller.ts @@ -18,14 +18,20 @@ import { WebhookService } from 'src/engine/core-modules/webhook/webhook.service' import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; 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 { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; /** * rest/webhooks is deprecated, use rest/metadata/webhooks instead * rest/webhooks will be removed in the future */ @Controller(['rest/webhooks', 'rest/metadata/webhooks']) -@UseGuards(JwtAuthGuard, WorkspaceAuthGuard) +@UseGuards( + JwtAuthGuard, + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS), +) @UseFilters(RestApiExceptionFilter) export class WebhookController { constructor(private readonly webhookService: WebhookService) {} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts b/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts index bc860d6aee..e4247c1299 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/controllers/workflow-trigger.controller.ts @@ -12,6 +12,7 @@ import { Request } from 'express'; import { isDefined } from 'twenty-shared/utils'; import { WorkflowTriggerRestApiExceptionFilter } from 'src/engine/core-modules/workflow/filters/workflow-trigger-rest-api-exception.filter'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; @@ -40,7 +41,7 @@ export class WorkflowTriggerController { ) {} @Post('workflows/:workspaceId/:workflowId') - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async runWorkflowByPostRequest( @Param('workspaceId') workspaceId: string, @Param('workflowId') workflowId: string, @@ -54,7 +55,7 @@ export class WorkflowTriggerController { } @Get('workflows/:workspaceId/:workflowId') - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async runWorkflowByGetRequest( @Param('workspaceId') workspaceId: string, @Param('workflowId') workflowId: string, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 5265dca2f7..7e8a0dcf26 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -104,7 +104,7 @@ export class WorkspaceResolver { ) {} @Query(() => WorkspaceEntity) - @UseGuards(WorkspaceAuthGuard) + @UseGuards(WorkspaceAuthGuard, NoPermissionGuard) async currentWorkspace(@AuthWorkspace() { id }: WorkspaceEntity) { const workspace = await this.workspaceService.findById(id); @@ -317,7 +317,7 @@ export class WorkspaceResolver { } @Query(() => PublicWorkspaceDataOutput) - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) async getPublicWorkspaceDataByDomain( @OriginHeader() originHeader: string, @Args('origin', { nullable: true }) origin?: string, diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts index 909bc7844d..759de7173d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-chat.controller.ts @@ -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: { diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts index 0cff039a4b..b38cf757f3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.resolver.ts @@ -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( diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts index f6eea134f4..8fc84a2bc1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-server.resolver.ts @@ -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() diff --git a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.resolver.ts index 7c1e30451e..1c468c1e06 100644 --- a/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/remote-server/remote-table/remote-table.resolver.ts @@ -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() diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts index e344a70ff1..8b56c9eac9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts @@ -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) {} diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts index ea3cc683ba..b452d3bb6d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/serverless-function.resolver.ts @@ -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) diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/controllers/view-field.controller.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/controllers/view-field.controller.ts index 1b73f723a7..554f088a2f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field/controllers/view-field.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/controllers/view-field.controller.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/resolvers/view-field.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/resolvers/view-field.resolver.ts index 60d1ad31a4..cb80703957 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field/resolvers/view-field.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/resolvers/view-field.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter-group/controllers/view-filter-group.controller.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter-group/controllers/view-filter-group.controller.ts index a6706a3c97..a45185929b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter-group/controllers/view-filter-group.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter-group/controllers/view-filter-group.controller.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter-group/resolvers/view-filter-group.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter-group/resolvers/view-filter-group.resolver.ts index 9176596e12..808b0b713c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter-group/resolvers/view-filter-group.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter-group/resolvers/view-filter-group.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter/controllers/view-filter.controller.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter/controllers/view-filter.controller.ts index c07383eb07..92869aa9f0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter/controllers/view-filter.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter/controllers/view-filter.controller.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver.ts index ef0bd2752a..f607b25112 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-group/controllers/view-group.controller.ts b/packages/twenty-server/src/engine/metadata-modules/view-group/controllers/view-group.controller.ts index 1a8ed77ac0..ca0fa9ec7b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-group/controllers/view-group.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-group/controllers/view-group.controller.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts index f7a1fe1afc..18d5029891 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard.ts b/packages/twenty-server/src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard.ts new file mode 100644 index 0000000000..5f1baf9050 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard.ts @@ -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 { + 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, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/view-permissions/services/view-access.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-permissions/services/view-access.service.ts index b6e9672d4c..2a6b35fb57 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-permissions/services/view-access.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-permissions/services/view-access.service.ts @@ -71,6 +71,41 @@ export class ViewAccessService { return this.checkViewAccess(view, userWorkspaceId, workspaceId); } + async canUserCreateView( + visibility: ViewVisibility, + userWorkspaceId: string | undefined, + workspaceId: string, + ): Promise { + // 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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts b/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts index 4b5de69ebe..b016adaddd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-sort/controllers/view-sort.controller.ts b/packages/twenty-server/src/engine/metadata-modules/view-sort/controllers/view-sort.controller.ts index 7db6dd0a44..d91cc74488 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-sort/controllers/view-sort.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-sort/controllers/view-sort.controller.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver.ts index 11e2c49efd..efe6c7858b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts b/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts index 57499ef8ab..4ff6943766 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts @@ -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 { 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( diff --git a/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts index 208527a181..553e121da0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts @@ -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 { 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 { 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) diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/tests/view.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/tests/view.service.spec.ts index a36b969679..0c19794f0a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/tests/view.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/tests/view.service.spec.ts @@ -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', () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts index c082328d3a..11d513b6ef 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts @@ -100,9 +100,11 @@ export class ViewV2Service { async updateOne({ updateViewInput, workspaceId, + userWorkspaceId, }: { updateViewInput: UpdateViewInput; workspaceId: string; + userWorkspaceId?: string; }): Promise { 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( { diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts index adf88f36b1..eeba3f5cf2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts @@ -193,6 +193,7 @@ export class ViewService { id: string, workspaceId: string, updateData: Partial, + userWorkspaceId?: string, ): Promise { 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); diff --git a/packages/twenty-server/src/engine/subscriptions/subscriptions.resolver.ts b/packages/twenty-server/src/engine/subscriptions/subscriptions.resolver.ts index d300d66396..2e8e517056 100644 --- a/packages/twenty-server/src/engine/subscriptions/subscriptions.resolver.ts +++ b/packages/twenty-server/src/engine/subscriptions/subscriptions.resolver.ts @@ -6,13 +6,14 @@ import { isDefined } from 'twenty-shared/utils'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { OnDbEventDTO } from 'src/engine/subscriptions/dtos/on-db-event.dto'; import { OnDbEventInput } from 'src/engine/subscriptions/dtos/on-db-event.input'; @Resolver() -@UseGuards(WorkspaceAuthGuard, UserAuthGuard) +@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard) @UsePipes(ResolverValidationPipe) @UseFilters(PreventNestToAutoLogGraphqlErrorsFilter) export class SubscriptionsResolver { diff --git a/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.spec.ts b/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.spec.ts index 55064c7cec..7584fed6f2 100644 --- a/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.spec.ts +++ b/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.spec.ts @@ -12,7 +12,7 @@ ruleTester.run(RULE_NAME, rule, { code: ` class TestResolver { @Query() - @UseGuards(UserAuthGuard) + @UseGuards(UserAuthGuard, NoPermissionGuard) testQuery() {} } `, @@ -21,7 +21,7 @@ ruleTester.run(RULE_NAME, rule, { code: ` class TestResolver { @Query() - @UseGuards(WorkspaceAuthGuard) + @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) testQuery() {} } `, @@ -30,23 +30,14 @@ ruleTester.run(RULE_NAME, rule, { code: ` class TestResolver { @Query() - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) testQuery() {} } `, }, { code: ` - class TestResolver { - @Query() - @UseGuards(CaptchaGuard, PublicEndpointGuard) - testQuery() {} - } - `, - }, - { - code: ` - @UseGuards(UserAuthGuard) + @UseGuards(UserAuthGuard, NoPermissionGuard) class TestResolver { @Query() testQuery() {} @@ -55,7 +46,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: ` - @UseGuards(WorkspaceAuthGuard) + @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) class TestResolver { @Query() testQuery() {} @@ -64,13 +55,31 @@ ruleTester.run(RULE_NAME, rule, { }, { code: ` - @UseGuards(PublicEndpointGuard) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) class TestResolver { @Query() testQuery() {} } `, }, + { + code: ` + class TestResolver { + @Subscription() + @UseGuards(WorkspaceAuthGuard, NoPermissionGuard) + testSubscription() {} + } + `, + }, + { + code: ` + @UseGuards(WorkspaceAuthGuard, NoPermissionGuard) + class TestResolver { + @Subscription() + testSubscription() {} + } + `, + }, { code: ` class TestResolver { @@ -163,6 +172,20 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: ` + class TestResolver { + @Query() + @UseGuards(UserAuthGuard) + testQuery() {} + } + `, + errors: [ + { + messageId: 'graphqlResolversShouldBeGuarded', + }, + ], + }, { code: ` class TestResolver { @@ -191,6 +214,34 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: ` + class TestResolver { + @Subscription() + @UseGuards(WorkspaceAuthGuard) + testSubscription() {} + } + `, + errors: [ + { + messageId: 'graphqlResolversShouldBeGuarded', + }, + ], + }, + { + code: ` + @UseGuards(WorkspaceAuthGuard) + class TestResolver { + @Subscription() + testSubscription() {} + } + `, + errors: [ + { + messageId: 'graphqlResolversShouldBeGuarded', + }, + ], + }, { code: ` class TestResolver { diff --git a/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.ts b/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.ts index bcc51c3cc0..272000f8c3 100644 --- a/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.ts +++ b/tools/eslint-rules/rules/graphql-resolvers-should-be-guarded.ts @@ -14,10 +14,6 @@ export const graphqlResolversShouldBeGuarded = ( ['Query', 'Mutation', 'Subscription'], ); - const isMutation = typedTokenHelpers.nodeHasDecoratorsNamed(node, [ - 'Mutation', - ]); - const hasAuthGuards = typedTokenHelpers.nodeHasAuthGuards(node); const hasPermissionsGuard = typedTokenHelpers.nodeHasPermissionsGuard(node); @@ -43,13 +39,12 @@ export const graphqlResolversShouldBeGuarded = ( ? typedTokenHelpers.nodeHasPermissionsGuard(classNode) : false; - // Basic requirement: all resolvers need auth guards + // All resolvers need both auth guards and permission guards const missingAuthGuard = hasGraphQLResolverDecorator && !hasAuthGuards && !hasAuthGuardsOnResolver; - // Additional requirement: mutations need permission guards const missingPermissionGuard = - isMutation && !hasPermissionsGuard && !hasPermissionsGuardOnResolver; + hasGraphQLResolverDecorator && !hasPermissionsGuard && !hasPermissionsGuardOnResolver; return missingAuthGuard || missingPermissionGuard; }; @@ -59,11 +54,11 @@ export const rule = createRule<[], 'graphqlResolversShouldBeGuarded'>({ meta: { docs: { description: - 'GraphQL root resolvers (Query, Mutation, Subscription) should have authentication guards (UserAuthGuard or WorkspaceAuthGuard) or be explicitly marked as public (PublicEndpointGuard) to maintain our security model. Mutations also require permission guards (SettingsPermissionsGuard or CustomPermissionGuard).', + 'GraphQL root resolvers (Query, Mutation, Subscription) should have authentication guards (UserAuthGuard or WorkspaceAuthGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionsGuard or CustomPermissionGuard) to maintain our security model.', }, messages: { graphqlResolversShouldBeGuarded: - 'All GraphQL resolvers must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard)). Mutations also require permission guards (@UseGuards(..., SettingsPermissionsGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases like onboarding).', + 'All GraphQL resolvers must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard)) and permission guards (@UseGuards(..., SettingsPermissionsGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases like onboarding).', }, schema: [], hasSuggestions: false, diff --git a/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.spec.ts b/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.spec.ts index 38eb7313b2..5643035f1a 100644 --- a/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.spec.ts +++ b/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.spec.ts @@ -12,7 +12,7 @@ ruleTester.run(RULE_NAME, rule, { code: ` class TestController { @Get() - @UseGuards(UserAuthGuard) + @UseGuards(UserAuthGuard, NoPermissionGuard) testMethod() {} } `, @@ -21,7 +21,7 @@ ruleTester.run(RULE_NAME, rule, { code: ` class TestController { @Get() - @UseGuards(WorkspaceAuthGuard) + @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) testMethod() {} } `, @@ -30,7 +30,7 @@ ruleTester.run(RULE_NAME, rule, { code: ` class TestController { @Get() - @UseGuards(PublicEndpoint) + @UseGuards(PublicEndpoint, NoPermissionGuard) testMethod() {} } `, @@ -39,14 +39,14 @@ ruleTester.run(RULE_NAME, rule, { code: ` class TestController { @Get() - @UseGuards(CaptchaGuard, PublicEndpoint) + @UseGuards(CaptchaGuard, PublicEndpoint, NoPermissionGuard) testMethod() {} } `, }, { code: ` - @UseGuards(UserAuthGuard) + @UseGuards(UserAuthGuard, NoPermissionGuard) class TestController { @Get() testMethod() {} @@ -55,7 +55,7 @@ ruleTester.run(RULE_NAME, rule, { }, { code: ` - @UseGuards(WorkspaceAuthGuard) + @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) class TestController { @Get() testMethod() {} @@ -64,13 +64,67 @@ ruleTester.run(RULE_NAME, rule, { }, { code: ` - @UseGuards(PublicEndpoint) + @UseGuards(PublicEndpoint, NoPermissionGuard) class TestController { @Get() testMethod() {} } `, }, + { + code: ` + class TestController { + @Post() + @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) + createMethod() {} + } + `, + }, + { + code: ` + class TestController { + @Put() + @UseGuards(WorkspaceAuthGuard, UpdatePermissionGuard) + updateMethod() {} + } + `, + }, + { + code: ` + class TestController { + @Patch() + @UseGuards(WorkspaceAuthGuard, NoPermissionGuard) + patchMethod() {} + } + `, + }, + { + code: ` + class TestController { + @Delete() + @UseGuards(WorkspaceAuthGuard, DeletePermissionGuard) + deleteMethod() {} + } + `, + }, + { + code: ` + @UseGuards(WorkspaceAuthGuard, CustomPermissionGuard) + class TestController { + @Post() + createMethod() {} + } + `, + }, + { + code: ` + @UseGuards(WorkspaceAuthGuard, SettingsPermissionsGuard(PermissionFlagType.WORKSPACE)) + class TestController { + @Delete() + deleteMethod() {} + } + `, + }, { code: ` class TestController { @@ -106,6 +160,20 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: ` + class TestController { + @Get() + @UseGuards(WorkspaceAuthGuard) + testMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, { code: ` class TestController { @@ -134,5 +202,103 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: ` + @UseGuards(WorkspaceAuthGuard) + class TestController { + @Get() + testMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, + { + code: ` + class TestController { + @Post() + @UseGuards(WorkspaceAuthGuard) + createMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, + { + code: ` + class TestController { + @Put() + @UseGuards(WorkspaceAuthGuard) + updateMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, + { + code: ` + class TestController { + @Patch() + @UseGuards(WorkspaceAuthGuard) + patchMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, + { + code: ` + class TestController { + @Delete() + @UseGuards(WorkspaceAuthGuard) + deleteMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, + { + code: ` + @UseGuards(WorkspaceAuthGuard) + class TestController { + @Post() + createMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, + { + code: ` + @UseGuards(WorkspaceAuthGuard) + class TestController { + @Delete() + deleteMethod() {} + } + `, + errors: [ + { + messageId: 'restApiMethodsShouldBeGuarded', + }, + ], + }, ], -}); +}); diff --git a/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.ts b/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.ts index 29a2e11b9e..6f43b64451 100644 --- a/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.ts +++ b/tools/eslint-rules/rules/rest-api-methods-should-be-guarded.ts @@ -13,6 +13,7 @@ export const restApiMethodsShouldBeGuarded = (node: TSESTree.MethodDefinition) = ); const hasAuthGuards = typedTokenHelpers.nodeHasAuthGuards(node); + const hasPermissionsGuard = typedTokenHelpers.nodeHasPermissionsGuard(node); function findClassDeclaration( node: TSESTree.Node @@ -32,11 +33,18 @@ export const restApiMethodsShouldBeGuarded = (node: TSESTree.MethodDefinition) = ? typedTokenHelpers.nodeHasAuthGuards(classNode) : false; - return ( - hasRestApiMethodDecorator && - !hasAuthGuards && - !hasAuthGuardsOnController - ); + const hasPermissionsGuardOnController = classNode + ? typedTokenHelpers.nodeHasPermissionsGuard(classNode) + : false; + + // All endpoints need both auth guards and permission guards + const missingAuthGuard = + hasRestApiMethodDecorator && !hasAuthGuards && !hasAuthGuardsOnController; + + const missingPermissionGuard = + hasRestApiMethodDecorator && !hasPermissionsGuard && !hasPermissionsGuardOnController; + + return missingAuthGuard || missingPermissionGuard; }; export const rule = createRule<[], 'restApiMethodsShouldBeGuarded'>({ @@ -44,11 +52,11 @@ export const rule = createRule<[], 'restApiMethodsShouldBeGuarded'>({ meta: { docs: { description: - 'REST API endpoints should have authentication guards (UserAuthGuard or WorkspaceAuthGuard) or be explicitly marked as public (PublicEndpointGuard) to maintain our security model.', + 'REST API endpoints should have authentication guards (UserAuthGuard or WorkspaceAuthGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionsGuard or CustomPermissionGuard) to maintain our security model.', }, messages: { restApiMethodsShouldBeGuarded: - 'All REST API controller endpoints should have @UseGuards(UserAuthGuard), @UseGuards(WorkspaceAuthGuard), or @UseGuards(PublicEndpointGuard) decorators, or one decorating the root of the Controller.', + 'All REST API controller endpoints must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard/PublicEndpointGuard)) and permission guards (@UseGuards(..., SettingsPermissionsGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases).', }, schema: [], hasSuggestions: false, @@ -67,4 +75,4 @@ export const rule = createRule<[], 'restApiMethodsShouldBeGuarded'>({ }, }; }, -}); +});