9cb21e71fa
## 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>
215 lines
7.7 KiB
TypeScript
215 lines
7.7 KiB
TypeScript
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
|
import { Args, Mutation, Subscription } from '@nestjs/graphql';
|
|
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
|
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
|
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
|
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
|
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
|
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
|
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
|
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
|
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
|
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
|
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
|
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
|
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
|
import { EVENT_STREAM_TTL_MS } from 'src/engine/subscriptions/constants/event-stream-ttl.constant';
|
|
import { AddQuerySubscriptionInput } from 'src/engine/subscriptions/dtos/add-query-subscription.input';
|
|
import { EventSubscriptionDTO } from 'src/engine/subscriptions/dtos/event-subscription.dto';
|
|
import { RemoveQueryFromEventStreamInput } from 'src/engine/subscriptions/dtos/remove-query-subscription.input';
|
|
import { EventStreamExceptionFilter } from 'src/engine/subscriptions/event-stream-exception.filter';
|
|
import {
|
|
EventStreamException,
|
|
EventStreamExceptionCode,
|
|
} from 'src/engine/subscriptions/event-stream.exception';
|
|
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
|
|
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
|
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
|
|
import { eventStreamIdToChannelId } from 'src/engine/subscriptions/utils/get-channel-id-from-event-stream-id';
|
|
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/subscriptions/utils/wrap-async-iterator-with-lifecycle';
|
|
|
|
@MetadataResolver()
|
|
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
|
|
@UsePipes(ResolverValidationPipe)
|
|
@UseFilters(EventStreamExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
|
|
export class EventStreamResolver {
|
|
constructor(
|
|
private readonly subscriptionService: SubscriptionService,
|
|
private readonly eventStreamService: EventStreamService,
|
|
) {}
|
|
|
|
@Subscription(() => EventSubscriptionDTO, {
|
|
nullable: true,
|
|
resolve: (
|
|
payload: EventStreamPayload,
|
|
variables: { eventStreamId: string },
|
|
) => {
|
|
return {
|
|
eventStreamId: variables.eventStreamId,
|
|
objectRecordEventsWithQueryIds: payload.objectRecordEventsWithQueryIds,
|
|
metadataEvents: payload.metadataEvents,
|
|
};
|
|
},
|
|
})
|
|
async onEventSubscription(
|
|
@Args('eventStreamId') eventStreamId: string,
|
|
@AuthWorkspace() workspace: WorkspaceEntity,
|
|
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
|
|
@AuthUserWorkspaceId({ allowUndefined: true })
|
|
userWorkspaceId: string | undefined,
|
|
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
|
) {
|
|
const eventStreamChannelId = eventStreamIdToChannelId(eventStreamId);
|
|
|
|
const streamData = await this.eventStreamService.getStreamData(
|
|
workspace.id,
|
|
eventStreamChannelId,
|
|
);
|
|
|
|
if (isDefined(streamData)) {
|
|
throw new EventStreamException(
|
|
'Event stream already exists',
|
|
EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS,
|
|
);
|
|
}
|
|
|
|
await this.eventStreamService.createEventStream({
|
|
workspaceId: workspace.id,
|
|
eventStreamChannelId,
|
|
authContext: {
|
|
userId: user?.id,
|
|
userWorkspaceId,
|
|
apiKeyId: apiKey?.id,
|
|
},
|
|
});
|
|
|
|
let iterator: AsyncIterableIterator<EventStreamPayload>;
|
|
|
|
try {
|
|
iterator = await this.subscriptionService.subscribeToEventStream({
|
|
workspaceId: workspace.id,
|
|
eventStreamChannelId,
|
|
});
|
|
} catch (error) {
|
|
await this.eventStreamService.destroyEventStream({
|
|
workspaceId: workspace.id,
|
|
eventStreamChannelId,
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
return wrapAsyncIteratorWithLifecycle(iterator, {
|
|
initialValue: {
|
|
objectRecordEventsWithQueryIds: [],
|
|
metadataEvents: [],
|
|
},
|
|
onHeartbeat: () =>
|
|
this.eventStreamService.refreshEventStreamTTL({
|
|
workspaceId: workspace.id,
|
|
eventStreamChannelId,
|
|
}),
|
|
heartbeatIntervalMs: EVENT_STREAM_TTL_MS / 5,
|
|
onCleanup: () =>
|
|
this.eventStreamService.destroyEventStream({
|
|
workspaceId: workspace.id,
|
|
eventStreamChannelId,
|
|
}),
|
|
});
|
|
}
|
|
|
|
@Mutation(() => Boolean)
|
|
async addQueryToEventStream(
|
|
@Args('input') input: AddQuerySubscriptionInput,
|
|
@AuthWorkspace() workspace: WorkspaceEntity,
|
|
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
|
|
@AuthUserWorkspaceId({ allowUndefined: true })
|
|
userWorkspaceId: string | undefined,
|
|
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
|
): Promise<boolean> {
|
|
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
|
|
const streamData = await this.eventStreamService.getStreamData(
|
|
workspace.id,
|
|
eventStreamChannelId,
|
|
);
|
|
|
|
if (!isDefined(streamData)) {
|
|
throw new EventStreamException(
|
|
'Event stream does not exist',
|
|
EventStreamExceptionCode.EVENT_STREAM_DOES_NOT_EXIST,
|
|
);
|
|
}
|
|
const isAuthorized = await this.eventStreamService.isAuthorized({
|
|
streamData,
|
|
authContext: {
|
|
userWorkspaceId,
|
|
apiKeyId: apiKey?.id,
|
|
},
|
|
});
|
|
|
|
if (!isAuthorized) {
|
|
throw new EventStreamException(
|
|
'You are not authorized to add a query to this event stream',
|
|
EventStreamExceptionCode.NOT_AUTHORIZED,
|
|
);
|
|
}
|
|
await this.eventStreamService.addQuery({
|
|
workspaceId: workspace.id,
|
|
eventStreamChannelId,
|
|
queryId: input.queryId,
|
|
operationSignature: input.operationSignature,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
@Mutation(() => Boolean)
|
|
async removeQueryFromEventStream(
|
|
@Args('input') input: RemoveQueryFromEventStreamInput,
|
|
@AuthWorkspace() workspace: WorkspaceEntity,
|
|
@AuthUser({ allowUndefined: true }) user: AuthContextUser | undefined,
|
|
@AuthUserWorkspaceId({ allowUndefined: true })
|
|
userWorkspaceId: string | undefined,
|
|
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
|
): Promise<boolean> {
|
|
const eventStreamChannelId = eventStreamIdToChannelId(input.eventStreamId);
|
|
|
|
const streamData = await this.eventStreamService.getStreamData(
|
|
workspace.id,
|
|
eventStreamChannelId,
|
|
);
|
|
|
|
if (!isDefined(streamData)) {
|
|
throw new EventStreamException(
|
|
'Event stream does not exist',
|
|
EventStreamExceptionCode.EVENT_STREAM_DOES_NOT_EXIST,
|
|
);
|
|
}
|
|
|
|
const isAuthorized = await this.eventStreamService.isAuthorized({
|
|
streamData,
|
|
authContext: {
|
|
userWorkspaceId,
|
|
apiKeyId: apiKey?.id,
|
|
},
|
|
});
|
|
|
|
if (!isAuthorized) {
|
|
throw new EventStreamException(
|
|
'You are not authorized to remove a query from this event stream',
|
|
EventStreamExceptionCode.NOT_AUTHORIZED,
|
|
);
|
|
}
|
|
|
|
await this.eventStreamService.removeQuery({
|
|
workspaceId: workspace.id,
|
|
eventStreamChannelId,
|
|
queryId: input.queryId,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
}
|