Resolve route-trigger workspace from bearer token on bare hosts (#23490)
When a request reaches the `/s` route on a host that names no workspace
(bare `SERVER_URL` on a multiworkspace instance), resolve the workspace
from the bearer token — the same source `/graphql` uses — instead of
failing with `WORKSPACE_NOT_FOUND`. Hosts that do name a workspace keep
host resolution unchanged, and requests without a token are unaffected.
This makes the client SDK's same-site `${apiBase}/s` fallback work on
multiworkspace instances without a configured public domain: app logic
functions calling their own HTTP routes (e.g. call-recorder artifact
import) currently 404 there, because `TWENTY_FUNCTIONS_URL` is injected
empty and the bare server host carries no workspace identity. Cloud
(workspace public origin injected) and single-workspace self-host (host
resolves the default workspace) never hit this path.
Note: this also allows public routes to be reached through a bare host
when a valid token identifies the workspace. It does not change route
authorization; the token is used only for workspace resolution.
This commit is contained in:
+122
-69
@@ -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<LogicFunctionEntity>,
|
||||
) {}
|
||||
|
||||
private async getLogicFunctionWithPathParamsOrFail({
|
||||
private async resolveAuthenticationContextForWorkspaceFallback({
|
||||
request,
|
||||
httpMethod,
|
||||
workspaceFromHost,
|
||||
}: {
|
||||
request: Request;
|
||||
httpMethod: HTTPMethod;
|
||||
}): Promise<{
|
||||
logicFunction: LogicFunctionEntity;
|
||||
pathParams: Partial<Record<string, string | string[]>>;
|
||||
isIsolatedOrigin: boolean;
|
||||
}> {
|
||||
workspaceFromHost: RouteTriggerWorkspace | undefined;
|
||||
}): Promise<AuthContext | undefined> {
|
||||
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<RouteTriggerRequestContext> {
|
||||
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<Record<string, string | string[]>>;
|
||||
} {
|
||||
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;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const sanitizeRouteTriggerPath = (requestPath: string): string =>
|
||||
requestPath.replace(/^\/s\//, '/');
|
||||
+89
-1
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user