diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts index 639f25b375..1748369b1d 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts @@ -10,9 +10,11 @@ import { IsNull, Not, Repository } from 'typeorm'; import { HTTPMethod } from 'twenty-shared/types'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { RouteTriggerException, @@ -20,6 +22,7 @@ import { } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception'; import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service'; import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; +import { sanitizeRouteTriggerPath } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/sanitize-route-trigger-path.util'; import { LogicFunctionException, LogicFunctionExceptionCode, @@ -31,6 +34,18 @@ import { } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { CustomException } from 'src/utils/custom-exception'; +type RouteTriggerWorkspace = Pick< + WorkspaceEntity, + 'activationStatus' | 'id' | 'subdomain' +>; + +type RouteTriggerRequestContext = { + workspace: RouteTriggerWorkspace; + applicationId: string | null; + isIsolatedOrigin: boolean; + authenticationContext: AuthContext | undefined; +}; + @Injectable() export class RouteTriggerService { private readonly logger = new Logger(RouteTriggerService.name); @@ -44,21 +59,51 @@ export class RouteTriggerService { private readonly logicFunctionRepository: Repository, ) {} - private async getLogicFunctionWithPathParamsOrFail({ + private async resolveAuthenticationContextForWorkspaceFallback({ request, - httpMethod, + workspaceFromHost, }: { request: Request; - httpMethod: HTTPMethod; - }): Promise<{ - logicFunction: LogicFunctionEntity; - pathParams: Partial>; - isIsolatedOrigin: boolean; - }> { + workspaceFromHost: RouteTriggerWorkspace | undefined; + }): Promise { + if ( + isDefined(workspaceFromHost) || + !isNonEmptyString(request.headers.authorization) + ) { + return undefined; + } + + try { + return await this.accessTokenService.validateTokenByRequest(request); + } catch (error) { + if (error instanceof AuthException) { + return undefined; + } + + throw error; + } + } + + private async resolveRouteTriggerRequestContextOrFail( + request: Request, + ): Promise { const host = `${request.protocol}://${request.get('host')}`; - const { workspace, publicDomain, isIsolatedOrigin } = - await this.workspaceDomainsService.resolveWorkspaceAndPublicDomain(host); + const { + workspace: workspaceFromHost, + publicDomain, + isIsolatedOrigin, + } = await this.workspaceDomainsService.resolveWorkspaceAndPublicDomain( + host, + ); + + const authenticationContext = + await this.resolveAuthenticationContextForWorkspaceFallback({ + request, + workspaceFromHost, + }); + + const workspace = workspaceFromHost ?? authenticationContext?.workspace; assertIsDefinedOrThrow( workspace, @@ -75,20 +120,26 @@ export class RouteTriggerService { ); } - // App-scoped public domain → restrict matches to that app's logic functions. - const applicationId = publicDomain?.applicationId ?? null; - - const logicFunctionsWithHttpRouteTrigger = - await this.logicFunctionRepository.find({ - where: { - workspaceId: workspace.id, - httpRouteTriggerSettings: Not(IsNull()), - ...(isDefined(applicationId) ? { applicationId } : {}), - }, - }); - - const requestPath = request.path.replace(/^\/s\//, '/'); + return { + workspace, + applicationId: publicDomain?.applicationId ?? null, + isIsolatedOrigin, + authenticationContext, + }; + } + private findLogicFunctionWithPathParamsOrFail({ + httpMethod, + logicFunctionsWithHttpRouteTrigger, + requestPath, + }: { + httpMethod: HTTPMethod; + logicFunctionsWithHttpRouteTrigger: LogicFunctionEntity[]; + requestPath: string; + }): { + logicFunction: LogicFunctionEntity; + pathParams: Partial>; + } { for (const logicFunction of logicFunctionsWithHttpRouteTrigger) { const httpRouteSettings = logicFunction.httpRouteTriggerSettings; @@ -105,16 +156,9 @@ export class RouteTriggerService { const routeMatched = routeMatcher(requestPath); if (routeMatched) { - this.assertLegacyRouteIsServableOrThrow({ - logicFunction, - workspace, - isIsolatedOrigin, - }); - return { logicFunction, pathParams: routeMatched.params, - isIsolatedOrigin, }; } } @@ -131,7 +175,7 @@ export class RouteTriggerService { isIsolatedOrigin, }: { logicFunction: LogicFunctionEntity; - workspace: WorkspaceEntity; + workspace: RouteTriggerWorkspace; isIsolatedOrigin: boolean; }) { if (isIsolatedOrigin) { @@ -177,33 +221,6 @@ export class RouteTriggerService { } } - private async validateWorkspaceFromRequest({ - request, - workspaceId, - }: { - request: Request; - workspaceId: string; - }) { - const authContext = - await this.accessTokenService.validateTokenByRequest(request); - - if (!isDefined(authContext.workspace)) { - throw new RouteTriggerException( - 'Workspace not found', - RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND, - ); - } - - if (authContext.workspace.id !== workspaceId) { - throw new RouteTriggerException( - 'You are not authorized', - RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION, - ); - } - - return authContext; - } - private mapErrorToRouteTriggerCode( error: unknown, ): RouteTriggerExceptionCode { @@ -235,25 +252,61 @@ export class RouteTriggerService { request: Request; httpMethod: HTTPMethod; }): Promise<{ response: RouteTriggerResponse; isIsolatedOrigin: boolean }> { - const { logicFunction, pathParams, isIsolatedOrigin } = - await this.getLogicFunctionWithPathParamsOrFail({ - request, - httpMethod, + const { + workspace, + applicationId, + isIsolatedOrigin, + authenticationContext, + } = await this.resolveRouteTriggerRequestContextOrFail(request); + + const logicFunctionsWithHttpRouteTrigger = + await this.logicFunctionRepository.find({ + where: { + workspaceId: workspace.id, + httpRouteTriggerSettings: Not(IsNull()), + ...(isDefined(applicationId) ? { applicationId } : {}), + }, }); + const { logicFunction, pathParams } = + this.findLogicFunctionWithPathParamsOrFail({ + httpMethod, + logicFunctionsWithHttpRouteTrigger, + requestPath: sanitizeRouteTriggerPath(request.path), + }); + + this.assertLegacyRouteIsServableOrThrow({ + logicFunction, + workspace, + isIsolatedOrigin, + }); + const httpRouteSettings = logicFunction.httpRouteTriggerSettings; let userWorkspaceId: string | null = null; let userId: string | null = null; if (httpRouteSettings?.isAuthRequired) { - const authContext = await this.validateWorkspaceFromRequest({ - request, - workspaceId: logicFunction.workspaceId, - }); + const routeAuthenticationContext = + authenticationContext ?? + (await this.accessTokenService.validateTokenByRequest(request)); - userWorkspaceId = authContext.userWorkspaceId ?? null; - userId = authContext.user?.id ?? null; + if (!isDefined(routeAuthenticationContext.workspace)) { + throw new RouteTriggerException( + 'Workspace not found', + RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND, + ); + } + + if (routeAuthenticationContext.workspace.id !== workspace.id) { + throw new RouteTriggerException( + 'You are not authorized', + RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + userWorkspaceId = routeAuthenticationContext.userWorkspaceId ?? null; + userId = routeAuthenticationContext.user?.id ?? null; } let outcome; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/sanitize-route-trigger-path.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/sanitize-route-trigger-path.util.ts new file mode 100644 index 0000000000..b948f78079 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/sanitize-route-trigger-path.util.ts @@ -0,0 +1,2 @@ +export const sanitizeRouteTriggerPath = (requestPath: string): string => + requestPath.replace(/^\/s\//, '/'); diff --git a/packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts b/packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts index 062062bc75..456ff5ef57 100644 --- a/packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts +++ b/packages/twenty-server/test/integration/route-trigger/suites/route-trigger-suspended-workspace.integration-spec.ts @@ -1,6 +1,8 @@ import request from 'supertest'; +import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util'; import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util'; import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util'; @@ -16,6 +18,8 @@ const APP_UNIVERSAL_IDENTIFIER = '6a6f983f-5c1a-4c60-a3c8-7d0e2a4a66a6'; const ROLE_UNIVERSAL_IDENTIFIER = '7b7f983f-5c1a-4c60-a3c8-7d0e2a4a77b7'; const ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER = '8c8f983f-5c1a-4c60-a3c8-7d0e2a4a88c8'; +const AUTHENTICATED_ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER = + '9d9f983f-5c1a-4c60-a3c8-7d0e2a4a99d9'; const ROUTE_FUNCTION_RESPONSE = { greeting: 'hello from route function' }; @@ -58,6 +62,20 @@ const routeFunctionManifest: LogicFunctionManifest = { }, }; +const authenticatedRouteFunctionManifest: LogicFunctionManifest = { + universalIdentifier: AUTHENTICATED_ROUTE_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'authenticated-workspace-route', + handlerName: 'main', + sourceHandlerPath: 'src/authenticated-workspace-route.ts', + builtHandlerPath: 'dist/authenticated-workspace-route.mjs', + builtHandlerChecksum: 'checksum-authenticated-workspace-route', + httpRouteTriggerSettings: { + path: '/authenticated-workspace-route', + httpMethod: 'GET', + isAuthRequired: true, + }, +}; + const uploadBuiltHandlerFile = async ({ builtHandlerPath, builtHandlerCode, @@ -82,7 +100,9 @@ const uploadBuiltHandlerFile = async ({ describe('RouteTrigger suspended workspace (integration)', () => { const baseUrl = `http://localhost:${APP_PORT}`; + const bareHost = `localhost:${APP_PORT}`; const workspaceHost = `apple.localhost:${APP_PORT}`; + let applicationAccessToken: string; beforeAll(async () => { await setupApplicationForSync({ @@ -97,17 +117,44 @@ describe('RouteTrigger suspended workspace (integration)', () => { builtHandlerCode: ROUTE_BUILT_HANDLER_CODE, }); + await uploadBuiltHandlerFile({ + builtHandlerPath: 'dist/authenticated-workspace-route.mjs', + builtHandlerCode: ROUTE_BUILT_HANDLER_CODE, + }); + await syncApplication({ manifest: buildBaseManifest({ appId: APP_UNIVERSAL_IDENTIFIER, roleId: ROLE_UNIVERSAL_IDENTIFIER, overrides: { - logicFunctions: [routeFunctionManifest], + logicFunctions: [ + routeFunctionManifest, + authenticatedRouteFunctionManifest, + ], }, }), expectToFail: false, }); + const { data: applicationsData } = await findManyApplications({ + expectToFail: false, + }); + const routeTriggerApplication = applicationsData.findManyApplications.find( + (application) => + application.universalIdentifier === APP_UNIVERSAL_IDENTIFIER, + ); + + expect(routeTriggerApplication).toBeDefined(); + + const { data: applicationTokenData } = await generateApplicationToken({ + applicationId: routeTriggerApplication!.id, + expectToFail: false, + }); + + applicationAccessToken = + applicationTokenData.generateApplicationToken.applicationAccessToken + .token; + jest.useRealTimers(); }, 60000); @@ -125,7 +172,48 @@ describe('RouteTrigger suspended workspace (integration)', () => { }); }, 60000); + describe('GET /s/authenticated-workspace-route', () => { + it('should serve an authenticated route when the bearer token resolves the workspace', async () => { + const response = await request(baseUrl) + .get('/s/authenticated-workspace-route') + .set('Host', bareHost) + .set('Authorization', `Bearer ${applicationAccessToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual(ROUTE_FUNCTION_RESPONSE); + }, 60000); + }); + describe('GET /s/suspended-workspace-route', () => { + it('should return WORKSPACE_NOT_FOUND when neither host nor token identifies a workspace', async () => { + const response = await request(baseUrl) + .get('/s/suspended-workspace-route') + .set('Host', bareHost); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('WORKSPACE_NOT_FOUND'); + }, 60000); + + it('should return WORKSPACE_NOT_FOUND when the bearer token is invalid on an unresolved host', async () => { + const response = await request(baseUrl) + .get('/s/suspended-workspace-route') + .set('Host', bareHost) + .set('Authorization', 'Bearer invalid-token'); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('WORKSPACE_NOT_FOUND'); + }, 60000); + + it('should resolve the workspace from the bearer token when the host names none', async () => { + const response = await request(baseUrl) + .get('/s/suspended-workspace-route') + .set('Host', bareHost) + .set('Authorization', `Bearer ${applicationAccessToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual(ROUTE_FUNCTION_RESPONSE); + }, 60000); + it('serves the route trigger while the workspace is active', async () => { const response = await request(baseUrl) .get('/s/suspended-workspace-route')