Enhance role-check system with stricter checks (#15392)

## Overview

This PR strengthens our permission system by introducing more granular
role-based access control across the platform.

## Changes

### New Permissions Added
- **Applications** - Control who can install and manage applications
- **Layouts** - Control who can customize page layouts and UI structure
- **AI** - Control access to AI features and agents
- **Upload File** - Separate permission for file uploads
- **Download File** - Separate permission for file downloads (frontend
visibility)

### Security Enhancements
- Implemented whitelist-based validation for workspace field updates
- Added explicit permission guards to core entity resolvers
- Enhanced ESLint rule to enforce permission checks on all mutations
- Created `CustomPermissionGuard` and `NoPermissionGuard` for better
code documentation

### Affected Components
- Core entity resolvers: webhooks, files, domains, applications,
layouts, postgres credentials
- Workspace update mutations now use whitelist validation
- Settings UI updated with new permission controls

### Developer Experience
- ESLint now catches missing permission guards during development
- Explicit guard markers make permission requirements clear in code
review
- Comprehensive test coverage for new permission logic

## Testing
-  All TypeScript type checks pass
-  ESLint validation passes
-  New permission guards properly enforced
-  Frontend UI displays new permissions correctly

## Migration Notes
Existing workspaces will need to assign the new permissions to roles as
needed. By default, all new permissions are set to `false` for non-admin
roles.
This commit is contained in:
Félix Malfait
2025-11-07 15:37:17 +01:00
committed by GitHub
parent 44d6ec2594
commit cff17db6cb
306 changed files with 13863 additions and 976 deletions
@@ -0,0 +1,45 @@
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';
@Injectable()
export class CreateViewFieldPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args.input or args.inputs
const args = gqlContext.getArgs();
if (typeof args?.input?.viewId === 'string') {
viewId = args.input.viewId;
}
if (!viewId && Array.isArray(args?.inputs) && args.inputs.length > 0) {
viewId =
typeof args.inputs[0]?.viewId === 'string'
? args.inputs[0].viewId
: null;
}
// For REST: extract from request body
if (!viewId && typeof request.body?.viewId === 'string') {
viewId = request.body.viewId;
}
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,38 @@
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';
@Injectable()
export class CreateViewFilterGroupPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args.input
const args = gqlContext.getArgs();
if (typeof args?.input?.viewId === 'string') {
viewId = args.input.viewId;
}
// For REST: extract from request body
if (!viewId && typeof request.body?.viewId === 'string') {
viewId = request.body.viewId;
}
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,38 @@
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';
@Injectable()
export class CreateViewFilterPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args.input
const args = gqlContext.getArgs();
if (typeof args?.input?.viewId === 'string') {
viewId = args.input.viewId;
}
// For REST: extract from request body
if (!viewId && typeof request.body?.viewId === 'string') {
viewId = request.body.viewId;
}
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,45 @@
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';
@Injectable()
export class CreateViewGroupPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args.input or args.inputs
const args = gqlContext.getArgs();
if (typeof args?.input?.viewId === 'string') {
viewId = args.input.viewId;
}
if (!viewId && Array.isArray(args?.inputs) && args.inputs.length > 0) {
viewId =
typeof args.inputs[0]?.viewId === 'string'
? args.inputs[0].viewId
: null;
}
// For REST: extract from request body
if (!viewId && typeof request.body?.viewId === 'string') {
viewId = request.body.viewId;
}
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,38 @@
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';
@Injectable()
export class CreateViewSortPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args.input
const args = gqlContext.getArgs();
if (typeof args?.input?.viewId === 'string') {
viewId = args.input.viewId;
}
// For REST: extract from request body
if (!viewId && typeof request.body?.viewId === 'string') {
viewId = request.body.viewId;
}
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DeleteViewFieldPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewField',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DeleteViewFilterGroupPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.id
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
entityId = args.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewFilterGroup',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DeleteViewFilterPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewFilter',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DeleteViewGroupPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewGroup',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,38 @@
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';
@Injectable()
export class DeleteViewPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
viewId = args.id;
}
// For REST: extract from URL params
if (!viewId && typeof request.params?.id === 'string') {
viewId = request.params.id;
}
return this.viewAccessService.canUserModifyView(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DeleteViewSortPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.id
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
entityId = args.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewSort',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DestroyViewFieldPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewField',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DestroyViewFilterGroupPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.id
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
entityId = args.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewFilterGroup',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DestroyViewFilterPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewFilter',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DestroyViewGroupPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewGroup',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,38 @@
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';
@Injectable()
export class DestroyViewPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
viewId = args.id;
}
// For REST: extract from URL params
if (!viewId && typeof request.params?.id === 'string') {
viewId = request.params.id;
}
return this.viewAccessService.canUserModifyView(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class DestroyViewSortPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.id
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
entityId = args.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewSort',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class UpdateViewFieldPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewField',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class UpdateViewFilterGroupPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.id
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
entityId = args.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewFilterGroup',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class UpdateViewFilterPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewFilter',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class UpdateViewGroupPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.input.id
const args = gqlContext.getArgs();
if (typeof args?.input?.id === 'string') {
entityId = args.input.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewGroup',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,38 @@
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';
@Injectable()
export class UpdateViewPermissionGuard implements CanActivate {
constructor(private readonly viewAccessService: ViewAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let viewId: string | null = null;
// For GraphQL: extract from args
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
viewId = args.id;
}
// For REST: extract from URL params
if (!viewId && typeof request.params?.id === 'string') {
viewId = request.params.id;
}
return this.viewAccessService.canUserModifyView(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,50 @@
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 { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
@Injectable()
export class UpdateViewSortPermissionGuard implements CanActivate {
constructor(
private readonly viewAccessService: ViewAccessService,
private readonly viewEntityLookupService: ViewEntityLookupService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const gqlContext = GqlExecutionContext.create(context);
const request = gqlContext.getContext().req;
let entityId = '';
// For GraphQL: extract from args.id
const args = gqlContext.getArgs();
if (typeof args?.id === 'string') {
entityId = args.id;
}
// For REST: extract from URL params
if (!entityId && typeof request.params?.id === 'string') {
entityId = request.params.id;
}
const viewId = entityId
? await this.viewEntityLookupService.findViewIdByEntityIdAndKind(
'viewSort',
entityId,
request.workspace.id,
)
: null;
return this.viewAccessService.canUserModifyViewByChildEntity(
viewId,
request.userWorkspaceId,
request.workspace.id,
);
}
}
@@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type 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 { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
@Injectable()
export class ViewAccessService {
constructor(
private readonly viewService: ViewService,
private readonly permissionsService: PermissionsService,
) {}
async canUserModifyView(
viewId: string | null,
userWorkspaceId: string | undefined,
workspaceId: string,
): Promise<boolean> {
// If viewId is null, the entity doesn't exist - allow the operation
// so the service can handle the NOT_FOUND error properly
if (!viewId) {
return true;
}
const view = await this.viewService.findByIdIncludingDeleted(
viewId,
workspaceId,
);
// If view doesn't exist, allow through to service for proper error message
if (!view) {
return true;
}
return this.checkViewAccess(view, userWorkspaceId, workspaceId);
}
async canUserModifyViewByChildEntity(
viewId: string | null,
userWorkspaceId: string | undefined,
workspaceId: string,
): Promise<boolean> {
// If viewId is null, the child entity doesn't exist
// Allow through so the service can throw the proper entity-specific error
// (e.g., "View field not found" instead of generic "View not found")
if (!viewId) {
return true;
}
const view = await this.viewService.findByIdIncludingDeleted(
viewId,
workspaceId,
);
// If view doesn't exist, allow through to service for proper error message
if (!view) {
return true;
}
return this.checkViewAccess(view, userWorkspaceId, workspaceId);
}
private async checkViewAccess(
view: ViewEntity,
userWorkspaceId: string | undefined,
workspaceId: string,
): Promise<boolean> {
const permissions = isDefined(userWorkspaceId)
? await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId,
workspaceId,
})
: null;
const hasViewsPermission =
permissions?.permissionFlags[PermissionFlagType.VIEWS] ?? false;
// Users with VIEWS permission can manipulate all views
if (hasViewsPermission) {
return true;
}
// Users without VIEWS permission can only manipulate their own unlisted views
const canAccess =
view.visibility === ViewVisibility.UNLISTED &&
view.createdByUserWorkspaceId === userWorkspaceId;
if (!canAccess) {
throw new ViewException(
generateViewExceptionMessage(
ViewExceptionMessageKey.VIEW_MODIFY_PERMISSION_DENIED,
),
ViewExceptionCode.VIEW_MODIFY_PERMISSION_DENIED,
{
userFriendlyMessage: generateViewUserFriendlyExceptionMessage(
ViewExceptionMessageKey.VIEW_MODIFY_PERMISSION_DENIED,
),
},
);
}
return true;
}
}
@@ -0,0 +1,89 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { type ViewChildEntityKind } from 'src/engine/metadata-modules/view-permissions/types/view-permissions.types';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
@Injectable()
export class ViewEntityLookupService {
constructor(
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
@InjectRepository(ViewFilterEntity)
private readonly viewFilterRepository: Repository<ViewFilterEntity>,
@InjectRepository(ViewFilterGroupEntity)
private readonly viewFilterGroupRepository: Repository<ViewFilterGroupEntity>,
@InjectRepository(ViewGroupEntity)
private readonly viewGroupRepository: Repository<ViewGroupEntity>,
@InjectRepository(ViewSortEntity)
private readonly viewSortRepository: Repository<ViewSortEntity>,
) {}
async findViewIdByEntityIdAndKind(
kind: ViewChildEntityKind,
entityId: string,
workspaceId: string,
): Promise<string | null> {
switch (kind) {
case 'viewField': {
const row = await this.viewFieldRepository.findOne({
where: { id: entityId, workspaceId },
select: ['viewId'],
});
if (row) return row.viewId;
break;
}
case 'viewFilter': {
const row = await this.viewFilterRepository.findOne({
where: { id: entityId, workspaceId },
select: ['viewId'],
});
if (row) return row.viewId;
break;
}
case 'viewFilterGroup': {
const row = await this.viewFilterGroupRepository.findOne({
where: { id: entityId, workspaceId },
select: ['viewId'],
});
if (row) return row.viewId;
break;
}
case 'viewGroup': {
const row = await this.viewGroupRepository.findOne({
where: { id: entityId, workspaceId },
select: ['viewId'],
});
if (row) return row.viewId;
break;
}
case 'viewSort': {
const row = await this.viewSortRepository.findOne({
where: { id: entityId, workspaceId },
select: ['viewId'],
});
if (row) return row.viewId;
break;
}
default:
break;
}
return null;
}
}
@@ -0,0 +1,6 @@
export type ViewChildEntityKind =
| 'viewField'
| 'viewFilter'
| 'viewFilterGroup'
| 'viewGroup'
| 'viewSort';
@@ -0,0 +1,111 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { CreateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-field-permission.guard';
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 { 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';
import { DeleteViewFilterPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-filter-permission.guard';
import { DeleteViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-group-permission.guard';
import { DeleteViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-permission.guard';
import { DeleteViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-sort-permission.guard';
import { DestroyViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-field-permission.guard';
import { DestroyViewFilterGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-filter-group-permission.guard';
import { DestroyViewFilterPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-filter-permission.guard';
import { DestroyViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-group-permission.guard';
import { DestroyViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-permission.guard';
import { DestroyViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-sort-permission.guard';
import { UpdateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-field-permission.guard';
import { UpdateViewFilterGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-filter-group-permission.guard';
import { UpdateViewFilterPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-filter-permission.guard';
import { UpdateViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-group-permission.guard';
import { UpdateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-permission.guard';
import { UpdateViewSortPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-sort-permission.guard';
import { ViewAccessService } from 'src/engine/metadata-modules/view-permissions/services/view-access.service';
import { ViewEntityLookupService } from 'src/engine/metadata-modules/view-permissions/services/view-entity-lookup.service';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([
ViewEntity,
ViewFieldEntity,
ViewFilterEntity,
ViewFilterGroupEntity,
ViewGroupEntity,
ViewSortEntity,
]),
I18nModule,
PermissionsModule,
WorkspaceCacheStorageModule,
],
providers: [
ViewService,
ViewEntityLookupService,
ViewAccessService,
UpdateViewPermissionGuard,
DeleteViewPermissionGuard,
DestroyViewPermissionGuard,
CreateViewFieldPermissionGuard,
UpdateViewFieldPermissionGuard,
DeleteViewFieldPermissionGuard,
DestroyViewFieldPermissionGuard,
CreateViewFilterPermissionGuard,
UpdateViewFilterPermissionGuard,
DeleteViewFilterPermissionGuard,
DestroyViewFilterPermissionGuard,
CreateViewFilterGroupPermissionGuard,
UpdateViewFilterGroupPermissionGuard,
DeleteViewFilterGroupPermissionGuard,
DestroyViewFilterGroupPermissionGuard,
CreateViewGroupPermissionGuard,
UpdateViewGroupPermissionGuard,
DeleteViewGroupPermissionGuard,
DestroyViewGroupPermissionGuard,
CreateViewSortPermissionGuard,
UpdateViewSortPermissionGuard,
DeleteViewSortPermissionGuard,
DestroyViewSortPermissionGuard,
],
exports: [
ViewService,
ViewEntityLookupService,
ViewAccessService,
UpdateViewPermissionGuard,
DeleteViewPermissionGuard,
DestroyViewPermissionGuard,
CreateViewFieldPermissionGuard,
UpdateViewFieldPermissionGuard,
DeleteViewFieldPermissionGuard,
DestroyViewFieldPermissionGuard,
CreateViewFilterPermissionGuard,
UpdateViewFilterPermissionGuard,
DeleteViewFilterPermissionGuard,
DestroyViewFilterPermissionGuard,
CreateViewFilterGroupPermissionGuard,
UpdateViewFilterGroupPermissionGuard,
DeleteViewFilterGroupPermissionGuard,
DestroyViewFilterGroupPermissionGuard,
CreateViewGroupPermissionGuard,
UpdateViewGroupPermissionGuard,
DeleteViewGroupPermissionGuard,
DestroyViewGroupPermissionGuard,
CreateViewSortPermissionGuard,
UpdateViewSortPermissionGuard,
DeleteViewSortPermissionGuard,
DestroyViewSortPermissionGuard,
],
})
export class ViewPermissionsModule {}