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:
+14
-1
@@ -21,8 +21,11 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { RequestLocale } from 'src/engine/decorators/locale/request-locale.decorator';
|
||||
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
|
||||
import { CreateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard';
|
||||
import { DeleteViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-permission.guard';
|
||||
import { UpdateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-permission.guard';
|
||||
import { CreateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/create-view.input';
|
||||
@@ -53,6 +56,7 @@ export class ViewController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(CustomPermissionGuard)
|
||||
async findMany(
|
||||
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@@ -71,6 +75,7 @@ export class ViewController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
|
||||
@@ -103,6 +108,7 @@ export class ViewController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(CreateViewPermissionGuard)
|
||||
async create(
|
||||
@Body() input: CreateViewInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@@ -144,6 +150,7 @@ export class ViewController {
|
||||
@Body() input: UpdateViewInput,
|
||||
@RequestLocale() locale: keyof typeof APP_LOCALES | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
): Promise<ViewDTO> {
|
||||
const isWorkspaceMigrationV2Enabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
@@ -160,9 +167,15 @@ export class ViewController {
|
||||
id,
|
||||
},
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId,
|
||||
});
|
||||
} else {
|
||||
updatedView = await this.viewService.update(id, workspace.id, input);
|
||||
updatedView = await this.viewService.update(
|
||||
id,
|
||||
workspace.id,
|
||||
input,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
const processedViews = await this.processViewsWithTemplates(
|
||||
|
||||
@@ -21,10 +21,9 @@ import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
|
||||
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
|
||||
@@ -34,6 +33,7 @@ import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view
|
||||
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
|
||||
import { ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
|
||||
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
|
||||
import { CreateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-permission.guard';
|
||||
import { DeleteViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-permission.guard';
|
||||
import { DestroyViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-permission.guard';
|
||||
import { UpdateViewPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-permission.guard';
|
||||
@@ -44,13 +44,6 @@ import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/up
|
||||
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
|
||||
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
||||
import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum';
|
||||
import {
|
||||
ViewException,
|
||||
ViewExceptionCode,
|
||||
ViewExceptionMessageKey,
|
||||
generateViewExceptionMessage,
|
||||
generateViewUserFriendlyExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
|
||||
import { ViewV2Service } from 'src/engine/metadata-modules/view/services/view-v2.service';
|
||||
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
|
||||
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
|
||||
@@ -70,7 +63,6 @@ export class ViewResolver {
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly viewV2Service: ViewV2Service,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => String)
|
||||
@@ -119,6 +111,7 @@ export class ViewResolver {
|
||||
}
|
||||
|
||||
@Query(() => [ViewDTO])
|
||||
@UseGuards(CustomPermissionGuard)
|
||||
async getCoreViews(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
@@ -137,6 +130,7 @@ export class ViewResolver {
|
||||
}
|
||||
|
||||
@Query(() => ViewDTO, { nullable: true })
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async getCoreView(
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@@ -152,7 +146,7 @@ export class ViewResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => ViewDTO)
|
||||
@UseGuards(CustomPermissionGuard)
|
||||
@UseGuards(CreateViewPermissionGuard)
|
||||
async createCoreView(
|
||||
@Args('input') input: CreateViewInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@@ -160,28 +154,6 @@ export class ViewResolver {
|
||||
): Promise<ViewDTO> {
|
||||
const visibility = input.visibility ?? ViewVisibility.WORKSPACE;
|
||||
|
||||
if (visibility === ViewVisibility.WORKSPACE && isDefined(userWorkspaceId)) {
|
||||
const permissions =
|
||||
await this.permissionsService.getUserWorkspacePermissions({
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!permissions.permissionFlags[PermissionFlagType.VIEWS]) {
|
||||
throw new ViewException(
|
||||
generateViewExceptionMessage(
|
||||
ViewExceptionMessageKey.VIEW_CREATE_PERMISSION_DENIED,
|
||||
),
|
||||
ViewExceptionCode.VIEW_CREATE_PERMISSION_DENIED,
|
||||
{
|
||||
userFriendlyMessage: generateViewUserFriendlyExceptionMessage(
|
||||
ViewExceptionMessageKey.VIEW_CREATE_PERMISSION_DENIED,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
input.visibility = visibility;
|
||||
|
||||
const isWorkspaceMigrationV2Enabled =
|
||||
@@ -213,6 +185,7 @@ export class ViewResolver {
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
@Args('input') input: UpdateViewInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
): Promise<ViewDTO> {
|
||||
const isWorkspaceMigrationV2Enabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
@@ -224,10 +197,11 @@ export class ViewResolver {
|
||||
return await this.viewV2Service.updateOne({
|
||||
updateViewInput: { ...input, id },
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.viewService.update(id, workspace.id, input);
|
||||
return this.viewService.update(id, workspace.id, input, userWorkspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
|
||||
+87
@@ -382,6 +382,93 @@ describe('ViewService', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should re-allocate view to current user when changing from WORKSPACE to UNLISTED visibility', async () => {
|
||||
const id = 'view-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const userWorkspaceId = 'current-user-workspace-id';
|
||||
const workspaceView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.WORKSPACE,
|
||||
createdByUserWorkspaceId: null,
|
||||
} as ViewEntity;
|
||||
const updateData = { visibility: ViewVisibility.UNLISTED };
|
||||
const expectedSaveData = {
|
||||
id,
|
||||
visibility: ViewVisibility.UNLISTED,
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
};
|
||||
const updatedView = {
|
||||
...workspaceView,
|
||||
...expectedSaveData,
|
||||
};
|
||||
|
||||
jest.spyOn(viewService, 'findById').mockResolvedValue(workspaceView);
|
||||
jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView);
|
||||
|
||||
const result = await viewService.update(
|
||||
id,
|
||||
workspaceId,
|
||||
updateData,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId);
|
||||
expect(viewRepository.save).toHaveBeenCalledWith(expectedSaveData);
|
||||
expect(result.createdByUserWorkspaceId).toBe(userWorkspaceId);
|
||||
});
|
||||
|
||||
it('should not change createdByUserWorkspaceId when visibility is not changing to UNLISTED', async () => {
|
||||
const id = 'view-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const userWorkspaceId = 'current-user-workspace-id';
|
||||
const updateData = { name: 'Updated Name' };
|
||||
const updatedView = { ...mockView, ...updateData };
|
||||
|
||||
jest.spyOn(viewService, 'findById').mockResolvedValue(mockView);
|
||||
jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView);
|
||||
|
||||
await viewService.update(id, workspaceId, updateData, userWorkspaceId);
|
||||
|
||||
expect(viewRepository.save).toHaveBeenCalledWith({
|
||||
id,
|
||||
...updateData,
|
||||
});
|
||||
expect(viewRepository.save).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not change createdByUserWorkspaceId when view is already UNLISTED', async () => {
|
||||
const id = 'view-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const userWorkspaceId = 'current-user-workspace-id';
|
||||
const originalOwner = 'original-owner-workspace-id';
|
||||
const unlistedView = {
|
||||
...mockView,
|
||||
visibility: ViewVisibility.UNLISTED,
|
||||
createdByUserWorkspaceId: originalOwner,
|
||||
} as ViewEntity;
|
||||
const updateData = { visibility: ViewVisibility.UNLISTED };
|
||||
const updatedView = { ...unlistedView, ...updateData };
|
||||
|
||||
jest.spyOn(viewService, 'findById').mockResolvedValue(unlistedView);
|
||||
jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView);
|
||||
|
||||
await viewService.update(id, workspaceId, updateData, userWorkspaceId);
|
||||
|
||||
expect(viewRepository.save).toHaveBeenCalledWith({
|
||||
id,
|
||||
...updateData,
|
||||
});
|
||||
expect(viewRepository.save).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -100,9 +100,11 @@ export class ViewV2Service {
|
||||
async updateOne({
|
||||
updateViewInput,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
updateViewInput: UpdateViewInput;
|
||||
workspaceId: string;
|
||||
userWorkspaceId?: string;
|
||||
}): Promise<ViewDTO> {
|
||||
const {
|
||||
flatViewMaps: existingFlatViewMaps,
|
||||
@@ -121,6 +123,21 @@ export class ViewV2Service {
|
||||
flatViewMaps: existingFlatViewMaps,
|
||||
});
|
||||
|
||||
const existingFlatView = existingFlatViewMaps.byId[updateViewInput.id];
|
||||
|
||||
// If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set
|
||||
// This prevents the view from disappearing for the user making the change
|
||||
if (
|
||||
isDefined(existingFlatView) &&
|
||||
isDefined(updateViewInput.visibility) &&
|
||||
updateViewInput.visibility === 'UNLISTED' &&
|
||||
existingFlatView.visibility === 'WORKSPACE' &&
|
||||
isDefined(userWorkspaceId)
|
||||
) {
|
||||
// Re-allocate the view to the current user
|
||||
flatViewFromUpdateInput.createdByUserWorkspaceId = userWorkspaceId;
|
||||
}
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
|
||||
@@ -193,6 +193,7 @@ export class ViewService {
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
updateData: Partial<ViewEntity>,
|
||||
userWorkspaceId?: string,
|
||||
): Promise<ViewEntity> {
|
||||
const existingView = await this.findById(id, workspaceId);
|
||||
|
||||
@@ -206,9 +207,23 @@ export class ViewService {
|
||||
);
|
||||
}
|
||||
|
||||
// If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set
|
||||
// This prevents the view from disappearing for the user making the change
|
||||
const dataToUpdate = { ...updateData };
|
||||
|
||||
if (
|
||||
isDefined(updateData.visibility) &&
|
||||
updateData.visibility === ViewVisibility.UNLISTED &&
|
||||
existingView.visibility === ViewVisibility.WORKSPACE &&
|
||||
isDefined(userWorkspaceId)
|
||||
) {
|
||||
// Re-allocate the view to the current user if it has no owner or a different owner
|
||||
dataToUpdate.createdByUserWorkspaceId = userWorkspaceId;
|
||||
}
|
||||
|
||||
const updatedView = await this.viewRepository.save({
|
||||
id,
|
||||
...updateData,
|
||||
...dataToUpdate,
|
||||
});
|
||||
|
||||
await this.flushGraphQLCache(workspaceId);
|
||||
|
||||
Reference in New Issue
Block a user