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

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

## Changes

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

### Permission Guards Added

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

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

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

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

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

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

The ESLint rules automatically enforce this pattern going forward.

## Stats
- 47 files changed
- 603 insertions, 163 deletions
- 3 new guard files created
This commit is contained in:
Félix Malfait
2025-11-10 12:17:38 +01:00
committed by GitHub
parent ae2d399d6e
commit 9a80164cf3
62 changed files with 727 additions and 147 deletions
@@ -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<WorkspaceInviteHashValidOutput> {
@@ -157,7 +157,7 @@ export class AuthResolver {
}
@Query(() => WorkspaceEntity)
@UseGuards(PublicEndpointGuard)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async findWorkspaceFromInviteHash(
@Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput,
): Promise<WorkspaceEntity> {
@@ -833,7 +833,7 @@ export class AuthResolver {
}
@Query(() => ValidatePasswordResetTokenOutput)
@UseGuards(PublicEndpointGuard)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async validatePasswordResetToken(
@Args() args: ValidatePasswordResetTokenInput,
): Promise<ValidatePasswordResetTokenOutput> {
@@ -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,
@@ -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(
@@ -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,
@@ -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,
@@ -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<string | void> {
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);