feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)
## Summary Builds on the messaging infrastructure migration (#18784) by securing and user-scoping all 4 metadata resolvers: ### DTOs secured - **ConnectedAccountDTO**: `@HideField()` on `accessToken`, `refreshToken`, `connectionParameters`, `oidcTokenClaims` - **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on `syncCursor` - **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId` - **UpdateMessageFolderInputUpdates**: stripped to only `isSynced` (removed `name`, `syncCursor`, `pendingSyncAction`) ### Resolvers user-scoped via `@AuthUserWorkspaceId()` - `myConnectedAccounts` — returns only the calling user's accounts (no permission guard) - `myMessageChannels(connectedAccountId?)` — returns channels for the user's connected accounts - `myCalendarChannels(connectedAccountId?)` — same pattern - `myMessageFolders(messageChannelId?)` — returns folders through the ownership chain ### Admin-only listing with permission guard - `connectedAccounts` query retained with `SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all workspace accounts ### Unsafe mutations removed - Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP flows create/refresh tokens server-side) - Removed `create*`/`delete*` mutations from MessageChannel, CalendarChannel, MessageFolder (managed by sync engine) ### Update mutations restricted with ownership verification - `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId === currentUserWorkspaceId` - `updateMessageChannel` / `updateCalendarChannel` / `updateMessageFolder` — verify ownership through connected account chain - New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in GraphQL ### `@AuthUserWorkspaceId` decorator hardened - Added `allowUndefined` option (default: `false`) — throws `ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key auth) - Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined: true })` where needed - New user-scoped resolvers enforce non-undefined `userWorkspaceId` at decorator level ### Exception handler chaining - `MessageFolderGraphqlApiExceptionInterceptor`, `MessageChannelGraphqlApiExceptionInterceptor`, `CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception handling (ConnectedAccountException, MessageChannelException) for correct `ForbiddenError` propagation ### Metadata services enhanced - `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`, `findByConnectedAccountIds()`, `findByMessageChannelIds()` - `findBy*ForUser()` methods encapsulate ownership checks before querying - `verifyOwnership()` on all 4 services with proper chain validation - Named parameters throughout for clarity ### Dev seeds for both schemas - Added JANE to connected account, message channel, calendar channel workspace seeds - Created message folder workspace seeds (TIM, JONY, JANE) - New `seed-metadata-entities.util.ts` seeds core schema tables (connectedAccount, messageChannel, calendarChannel, messageFolder) with same IDs as workspace seeds, mapping `accountOwnerId` → `userWorkspaceId` ### Integration tests (using seeds, not raw SQL) - 4 test suites (`connected-account`, `message-channel`, `calendar-channel`, `message-folder`) - Tests use seeded data IDs from seed constants — no raw SQL inserts/deletes - Tests read via GraphQL resolvers - Tests cover: user scoping, admin permission checks, sensitive field exclusion, ownership enforcement on mutations ### Frontend migration - Feature-flag-gated hooks (`useMyConnectedAccounts`, `useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`) - When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API (`POST /metadata`) - When flag is off: hooks use existing workspace API (`POST /graphql`, current behavior) - Settings account pages updated to use new hooks - `useEffect` extracted to `SettingsAccountsSelectedMessageChannelEffect` component per project conventions - Error messages translated with Lingui ## Test plan - [x] Server typecheck passes - [x] Server lint passes - [x] Server unit tests pass (477 suites, 4269 tests) - [x] Frontend typecheck passes - [x] Frontend lint passes - [x] Integration tests verify user-scoping, ownership enforcement, hidden fields - [ ] CI green --------- Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
+227
@@ -0,0 +1,227 @@
|
||||
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { CalendarFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service';
|
||||
|
||||
const mockCalendarChannelSyncStatusService = {
|
||||
markAsCalendarEventListFetchOngoing: jest.fn(),
|
||||
markAsCalendarEventListFetchPending: jest.fn(),
|
||||
markAsCalendarEventsImportPending: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGetCalendarEventsService = {
|
||||
getCalendarEvents: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCalendarChannelDataAccessService = {
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCalendarAccountAuthenticationService = {
|
||||
validateAndRefreshConnectedAccountAuthentication: jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
accessToken: 'fresh-access-token',
|
||||
refreshToken: 'fresh-refresh-token',
|
||||
}),
|
||||
};
|
||||
|
||||
const mockCalendarEventsImportService = {
|
||||
processCalendarEventsImport: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCalendarEventImportErrorHandlerService = {
|
||||
handleDriverException: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCacheStorage = {
|
||||
setAdd: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGlobalWorkspaceOrmManager = {
|
||||
executeInWorkspaceContext: jest.fn(async (callback: () => Promise<void>) => {
|
||||
await callback();
|
||||
}),
|
||||
};
|
||||
|
||||
const workspaceId = 'workspace-123';
|
||||
|
||||
const baseConnectedAccount = {
|
||||
id: 'account-123',
|
||||
provider: 'google',
|
||||
refreshToken: 'refresh-token',
|
||||
accessToken: 'access-token',
|
||||
handle: 'test@example.com',
|
||||
} as unknown as ConnectedAccountWorkspaceEntity;
|
||||
|
||||
const createCalendarChannel = (
|
||||
syncCursor: string | null,
|
||||
): CalendarChannelWorkspaceEntity =>
|
||||
({
|
||||
id: 'channel-123',
|
||||
syncCursor,
|
||||
connectedAccountId: 'account-123',
|
||||
}) as unknown as CalendarChannelWorkspaceEntity;
|
||||
|
||||
describe('CalendarFetchEventsService', () => {
|
||||
let service: CalendarFetchEventsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockGetCalendarEventsService.getCalendarEvents.mockResolvedValue({
|
||||
fullEvents: true,
|
||||
calendarEvents: [{ id: 'event-1' }],
|
||||
nextSyncCursor: 'new-cursor-abc',
|
||||
});
|
||||
|
||||
service = new CalendarFetchEventsService(
|
||||
mockCacheStorage as any,
|
||||
mockGlobalWorkspaceOrmManager as any,
|
||||
mockCalendarChannelDataAccessService as any,
|
||||
mockCalendarChannelSyncStatusService as any,
|
||||
mockGetCalendarEventsService as any,
|
||||
mockCalendarEventImportErrorHandlerService as any,
|
||||
mockCalendarEventsImportService as any,
|
||||
mockCalendarAccountAuthenticationService as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('syncCursor handling (backwards compatibility)', () => {
|
||||
it('should perform full sync when syncCursor is null (core schema)', async () => {
|
||||
const calendarChannel = createCalendarChannel(null);
|
||||
|
||||
await service.fetchCalendarEvents(
|
||||
calendarChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockGetCalendarEventsService.getCalendarEvents,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ handle: 'test@example.com' }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should perform full sync when syncCursor is empty string (workspace schema)', async () => {
|
||||
const calendarChannel = createCalendarChannel('');
|
||||
|
||||
await service.fetchCalendarEvents(
|
||||
calendarChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockGetCalendarEventsService.getCalendarEvents,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ handle: 'test@example.com' }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use existing syncCursor for incremental sync', async () => {
|
||||
const calendarChannel = createCalendarChannel('cursor-xyz');
|
||||
|
||||
await service.fetchCalendarEvents(
|
||||
calendarChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockGetCalendarEventsService.getCalendarEvents,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ handle: 'test@example.com' }),
|
||||
'cursor-xyz',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update syncCursor after successful fetch regardless of initial cursor state', async () => {
|
||||
const calendarChannel = createCalendarChannel(null);
|
||||
|
||||
await service.fetchCalendarEvents(
|
||||
calendarChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(mockCalendarChannelDataAccessService.update).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
{ id: 'channel-123' },
|
||||
{ syncCursor: 'new-cursor-abc' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when feature flag switches from off (workspace empty string) to on (core null)', async () => {
|
||||
// Simulate flag OFF: workspace returns empty string
|
||||
const workspaceChannel = createCalendarChannel('');
|
||||
|
||||
await expect(
|
||||
service.fetchCalendarEvents(
|
||||
workspaceChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockGetCalendarEventsService.getCalendarEvents.mockResolvedValue({
|
||||
fullEvents: true,
|
||||
calendarEvents: [{ id: 'event-2' }],
|
||||
nextSyncCursor: 'new-cursor-def',
|
||||
});
|
||||
|
||||
// Simulate flag ON: core returns null
|
||||
const coreChannel = createCalendarChannel(null);
|
||||
|
||||
await expect(
|
||||
service.fetchCalendarEvents(
|
||||
coreChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(
|
||||
mockGetCalendarEventsService.getCalendarEvents,
|
||||
).toHaveBeenCalledWith(expect.anything(), undefined);
|
||||
});
|
||||
|
||||
it('should preserve incremental sync behavior when toggling feature flag with existing cursor', async () => {
|
||||
// Flag OFF: workspace has cursor
|
||||
const workspaceChannel = createCalendarChannel('cursor-from-workspace');
|
||||
|
||||
await service.fetchCalendarEvents(
|
||||
workspaceChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockGetCalendarEventsService.getCalendarEvents,
|
||||
).toHaveBeenCalledWith(expect.anything(), 'cursor-from-workspace');
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockGetCalendarEventsService.getCalendarEvents.mockResolvedValue({
|
||||
fullEvents: true,
|
||||
calendarEvents: [{ id: 'event-3' }],
|
||||
nextSyncCursor: 'newer-cursor',
|
||||
});
|
||||
|
||||
// Flag ON: core has same cursor (dual-written)
|
||||
const coreChannel = createCalendarChannel('cursor-from-workspace');
|
||||
|
||||
await service.fetchCalendarEvents(
|
||||
coreChannel,
|
||||
baseConnectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockGetCalendarEventsService.getCalendarEvents,
|
||||
).toHaveBeenCalledWith(expect.anything(), 'cursor-from-workspace');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-10
@@ -1,7 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
@@ -71,17 +69,10 @@ export class CalendarFetchEventsService {
|
||||
refreshToken,
|
||||
};
|
||||
|
||||
if (!isDefined(calendarChannel.syncCursor)) {
|
||||
throw new CalendarEventImportDriverException(
|
||||
'Sync cursor is required',
|
||||
CalendarEventImportDriverExceptionCode.SYNC_CURSOR_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const getCalendarEventsResponse =
|
||||
await this.getCalendarEventsService.getCalendarEvents(
|
||||
connectedAccountWithFreshTokens,
|
||||
calendarChannel.syncCursor,
|
||||
calendarChannel.syncCursor || undefined,
|
||||
);
|
||||
|
||||
const hasFullEvents = getCalendarEventsResponse.fullEvents;
|
||||
|
||||
Reference in New Issue
Block a user