Files
twenty/packages/twenty-server/src/engine/metadata-modules/view-permissions/services/view-access.service.ts
T
eeeggg 65dced14ff fix: enable API key management of workspace views + fix permission bypass vulnerability (#16768)
Fixes #16739

- Remove empty string coercion in createCoreView that caused PostgreSQL
UUID errors for API keys
- Add permission check allowing API keys with VIEWS permission to manage
workspace views they created

API keys with 'Manage Views' permission can now create, update, and
delete workspace views via both GraphQL and REST APIs.
2025-12-23 09:06:47 +01:00

176 lines
5.1 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'twenty-shared/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,
apiKeyId?: 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, apiKeyId);
}
async canUserModifyViewByChildEntity(
viewId: string | null,
userWorkspaceId: string | undefined,
workspaceId: string,
apiKeyId?: 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, apiKeyId);
}
async canUserCreateView(
visibility: ViewVisibility,
userWorkspaceId: string | undefined,
workspaceId: string,
apiKeyId?: string,
): Promise<boolean> {
// For WORKSPACE visibility views, check VIEWS permission
if (visibility === ViewVisibility.WORKSPACE) {
let hasViewsPermission = false;
if (isDefined(userWorkspaceId)) {
const permissions =
await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId,
workspaceId,
});
hasViewsPermission =
permissions.permissionFlags[PermissionFlagType.VIEWS] ?? false;
} else if (isDefined(apiKeyId)) {
hasViewsPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
workspaceId,
apiKeyId,
setting: PermissionFlagType.VIEWS,
});
}
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, allow creation
return true;
}
private async checkViewAccess(
view: ViewEntity,
userWorkspaceId: string | undefined,
workspaceId: string,
apiKeyId?: string,
): Promise<boolean> {
let hasViewsPermission = false;
if (isDefined(userWorkspaceId)) {
const permissions =
await this.permissionsService.getUserWorkspacePermissions({
userWorkspaceId,
workspaceId,
});
hasViewsPermission =
permissions.permissionFlags[PermissionFlagType.VIEWS] ?? false;
} else if (isDefined(apiKeyId)) {
hasViewsPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
workspaceId,
apiKeyId,
setting: PermissionFlagType.VIEWS,
});
}
// Users/API keys 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;
}
}