Add WorkspaceAuthContextMiddleware (#17487)
## Context
Introduces a middleware that automatically sets the workspace auth
context in AsyncLocalStorage for HTTP requests, making it available
throughout the request lifecycle without explicit parameter passing.
The motivation behind this change is to reduce boilerplate and simplify
the developer experience when working with workspace data in HTTP
request handlers.
The Problem (Before)
Every HTTP request handler that needed to access workspace data had to:
- Extract auth-related info from decorators (@AuthWorkspace(),
@AuthUserWorkspaceId(), etc.) in controller/resolver and pass down to
services
- Build or pass the authContext explicitly (sometimes with type
assertion which was flaky)
Then call executeInWorkspaceContext(authContext, async () => { ... })
## Changes
- Add WorkspaceAuthContextMiddleware that extracts auth context from the
request and stores it in AsyncLocalStorage
- Register middleware for GraphQL, metadata, and REST routes (runs after
hydration middlewares)
- Simplify executeInWorkspaceContext signature: fn is now the first
parameter, authContext is optional second
- If authContext is not provided, it's automatically retrieved from the
storage (set by middleware)
- Update all callers (~120 files) to use the new parameter order
- Fixes a bug in search where system auth context was used, bypassing
RLS feature.
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
|
||||
|
||||
import { withWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceAuthContextMiddleware implements NestMiddleware {
|
||||
use(req: Request, _res: Response, next: NextFunction) {
|
||||
if (!isDefined(req.workspace)) {
|
||||
next();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const authContext: WorkspaceAuthContext = {
|
||||
user: req.user,
|
||||
workspace: req.workspace,
|
||||
workspaceMemberId: req.workspaceMemberId,
|
||||
workspaceMember: req.workspaceMember,
|
||||
userWorkspaceId: req.userWorkspaceId,
|
||||
apiKey: req.apiKey,
|
||||
application: req.application,
|
||||
} as WorkspaceAuthContext;
|
||||
|
||||
withWorkspaceAuthContext(authContext, () => {
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -42,7 +42,6 @@ export class CreateCalendarChannelService {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const calendarChannelRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
|
||||
@@ -70,6 +69,7 @@ export class CreateCalendarChannelService {
|
||||
|
||||
return newCalendarChannel.id;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-23
@@ -42,29 +42,26 @@ export class CreateConnectedAccountService {
|
||||
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const connectedAccountRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'connectedAccount',
|
||||
);
|
||||
|
||||
await connectedAccountRepository.save(
|
||||
{
|
||||
id: connectedAccountId,
|
||||
handle,
|
||||
provider,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accountOwnerId,
|
||||
scopes,
|
||||
},
|
||||
{},
|
||||
manager,
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const connectedAccountRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'connectedAccount',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await connectedAccountRepository.save(
|
||||
{
|
||||
id: connectedAccountId,
|
||||
handle,
|
||||
provider,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accountOwnerId,
|
||||
scopes,
|
||||
},
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
}, authContext);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -47,7 +47,6 @@ export class CreateMessageChannelService {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const messageChannelRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
|
||||
@@ -93,6 +92,7 @@ export class CreateMessageChannelService {
|
||||
|
||||
return newMessageChannelId;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ describe('GoogleAPIsService', () => {
|
||||
.mockResolvedValue(mockWorkspaceDataSource),
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation((_authContext: any, fn: () => any) => fn()),
|
||||
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -100,7 +100,6 @@ export class GoogleAPIsService {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const connectedAccountRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
@@ -258,6 +257,7 @@ export class GoogleAPIsService {
|
||||
|
||||
return newOrExistingConnectedAccountId;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ describe('MicrosoftAPIsService', () => {
|
||||
.mockResolvedValue(mockWorkspaceDataSource),
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation((_authContext: any, fn: () => any) => fn()),
|
||||
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -81,7 +81,6 @@ export class MicrosoftAPIsService {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const connectedAccountRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
@@ -247,6 +246,7 @@ export class MicrosoftAPIsService {
|
||||
|
||||
return newOrExistingConnectedAccountId;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-22
@@ -35,28 +35,25 @@ export class UpdateConnectedAccountOnReconnectService {
|
||||
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const connectedAccountRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'connectedAccount',
|
||||
);
|
||||
|
||||
await connectedAccountRepository.update(
|
||||
{
|
||||
id: connectedAccountId,
|
||||
},
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scopes,
|
||||
authFailedAt: null,
|
||||
},
|
||||
manager,
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const connectedAccountRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'connectedAccount',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await connectedAccountRepository.update(
|
||||
{
|
||||
id: connectedAccountId,
|
||||
},
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scopes,
|
||||
authFailedAt: null,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
}, authContext);
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
|
||||
|
||||
export const workspaceAuthContextStorage =
|
||||
new AsyncLocalStorage<WorkspaceAuthContext>();
|
||||
|
||||
export const getWorkspaceAuthContext = (): WorkspaceAuthContext => {
|
||||
const context = workspaceAuthContextStorage.getStore();
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Workspace auth context not set. Operations must be wrapped with withWorkspaceAuthContext()',
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const withWorkspaceAuthContext = <T>(
|
||||
context: WorkspaceAuthContext,
|
||||
fn: () => T | Promise<T>,
|
||||
): T | Promise<T> => {
|
||||
return workspaceAuthContextStorage.run(context, fn);
|
||||
};
|
||||
+1
-1
@@ -82,7 +82,7 @@ describe('AccessTokenService', () => {
|
||||
getRepository: jest.fn(),
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation((_authContext: any, fn: () => any) => fn()),
|
||||
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+1
-1
@@ -84,7 +84,6 @@ export class AccessTokenService {
|
||||
|
||||
tokenWorkspaceMemberId =
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const workspaceMemberRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
@@ -112,6 +111,7 @@ export class AccessTokenService {
|
||||
|
||||
return workspaceMember.id;
|
||||
},
|
||||
authContext,
|
||||
);
|
||||
}
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
|
||||
Reference in New Issue
Block a user