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:
Charles Bochet
2026-03-20 17:22:22 +01:00
committed by GitHub
parent a8625d8bfb
commit 9cb21e71fa
88 changed files with 3412 additions and 1233 deletions
@@ -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,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;