[Permissions] Add userWorkspaceId to JWT token (#9954)
This information will be used to fetch a user's role and check their permissions
This commit is contained in:
1
packages/twenty-server/@types/express.d.ts
vendored
1
packages/twenty-server/@types/express.d.ts
vendored
@ -10,5 +10,6 @@ declare module 'express-serve-static-core' {
|
||||
workspaceId?: string;
|
||||
workspaceMetadataVersion?: number;
|
||||
workspaceMemberId?: string;
|
||||
userWorkspaceId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ export class AuthException extends CustomException {
|
||||
|
||||
export enum AuthExceptionCode {
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
USER_WORKSPACE_NOT_FOUND = 'USER_WORKSPACE_NOT_FOUND',
|
||||
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
|
||||
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
|
||||
@ -29,11 +29,13 @@ import { EmailVerificationModule } from 'src/engine/core-modules/email-verificat
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
@ -45,7 +47,6 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
@ -70,6 +71,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
FeatureFlag,
|
||||
WorkspaceSSOIdentityProvider,
|
||||
KeyValuePair,
|
||||
UserWorkspace,
|
||||
],
|
||||
'core',
|
||||
),
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { JwtAuthStrategy } from './jwt.auth.strategy';
|
||||
@ -11,6 +12,7 @@ describe('JwtAuthStrategy', () => {
|
||||
let strategy: JwtAuthStrategy;
|
||||
|
||||
let workspaceRepository: any;
|
||||
let userWorkspaceRepository: any;
|
||||
let userRepository: any;
|
||||
let dataSourceService: any;
|
||||
let typeORMService: any;
|
||||
@ -27,6 +29,10 @@ describe('JwtAuthStrategy', () => {
|
||||
findOne: jest.fn(async () => null),
|
||||
};
|
||||
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn(async () => new UserWorkspace()),
|
||||
};
|
||||
|
||||
// first we test the API_KEY case
|
||||
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
|
||||
const payload = {
|
||||
@ -45,6 +51,7 @@ describe('JwtAuthStrategy', () => {
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
{} as any,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
@ -80,6 +87,7 @@ describe('JwtAuthStrategy', () => {
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
{} as any,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
@ -117,6 +125,7 @@ describe('JwtAuthStrategy', () => {
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
{} as any,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
const result = await strategy.validate(payload as JwtPayload);
|
||||
@ -148,6 +157,7 @@ describe('JwtAuthStrategy', () => {
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
@ -160,7 +170,7 @@ describe('JwtAuthStrategy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should be truthy if type is ACCESS, no jti, and user exist', async () => {
|
||||
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
@ -174,6 +184,10 @@ describe('JwtAuthStrategy', () => {
|
||||
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
|
||||
};
|
||||
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn(async () => null),
|
||||
};
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
@ -181,10 +195,52 @@ describe('JwtAuthStrategy', () => {
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('UserWorkspace not found', expect.any(String)),
|
||||
);
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be truthy if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
|
||||
const payload = {
|
||||
sub: 'sub-default',
|
||||
type: 'ACCESS',
|
||||
};
|
||||
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(async () => new Workspace()),
|
||||
};
|
||||
|
||||
userRepository = {
|
||||
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
|
||||
};
|
||||
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn(async () => ({
|
||||
id: 'userWorkspaceId',
|
||||
})),
|
||||
};
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
typeORMService,
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
);
|
||||
|
||||
const user = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
expect(user.user?.lastName).toBe('lastNameDefault');
|
||||
expect(user.userWorkspaceId).toBe('userWorkspaceId');
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
@ -32,6 +33,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@ -117,7 +120,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
return { user, workspace };
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: {
|
||||
id: payload.userWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWorkspace) {
|
||||
throw new AuthException(
|
||||
'UserWorkspace not found',
|
||||
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return { user, workspace, userWorkspaceId: userWorkspace.id };
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthContext> {
|
||||
|
||||
@ -12,6 +12,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
@ -25,6 +26,7 @@ describe('AccessTokenService', () => {
|
||||
let userRepository: Repository<User>;
|
||||
let workspaceRepository: Repository<Workspace>;
|
||||
let twentyORMGlobalManager: TwentyORMGlobalManager;
|
||||
let userWorkspaceRepository: Repository<UserWorkspace>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@ -63,6 +65,10 @@ describe('AccessTokenService', () => {
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {},
|
||||
@ -92,6 +98,9 @@ describe('AccessTokenService', () => {
|
||||
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
|
||||
TwentyORMGlobalManager,
|
||||
);
|
||||
userWorkspaceRepository = module.get<Repository<UserWorkspace>>(
|
||||
getRepositoryToken(UserWorkspace, 'core'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@ -109,6 +118,7 @@ describe('AccessTokenService', () => {
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
id: workspaceId,
|
||||
};
|
||||
const mockUserWorkspace = { id: 'userWorkspaceId' };
|
||||
const mockWorkspaceMember = { id: 'workspace-member-id' };
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
@ -117,6 +127,9 @@ describe('AccessTokenService', () => {
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as Workspace);
|
||||
jest
|
||||
.spyOn(userWorkspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockUserWorkspace as UserWorkspace);
|
||||
jest
|
||||
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue({
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@ -38,6 +39,8 @@ export class AccessTokenService {
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
) {}
|
||||
|
||||
async generateAccessToken(
|
||||
@ -87,11 +90,18 @@ export class AccessTokenService {
|
||||
|
||||
tokenWorkspaceMemberId = workspaceMember.id;
|
||||
}
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
const jwtPayload: JwtPayload = {
|
||||
sub: user.id,
|
||||
workspaceId,
|
||||
workspaceMemberId: tokenWorkspaceMemberId,
|
||||
userWorkspaceId: userWorkspace?.id,
|
||||
};
|
||||
|
||||
return {
|
||||
@ -108,10 +118,10 @@ export class AccessTokenService {
|
||||
|
||||
const decoded = await this.jwtWrapperService.decode(token);
|
||||
|
||||
const { user, apiKey, workspace, workspaceMemberId } =
|
||||
const { user, apiKey, workspace, workspaceMemberId, userWorkspaceId } =
|
||||
await this.jwtStrategy.validate(decoded as JwtPayload);
|
||||
|
||||
return { user, apiKey, workspace, workspaceMemberId };
|
||||
return { user, apiKey, workspace, workspaceMemberId, userWorkspaceId };
|
||||
}
|
||||
|
||||
async validateTokenByRequest(request: Request): Promise<AuthContext> {
|
||||
|
||||
@ -12,15 +12,19 @@ import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/r
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule,
|
||||
TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'),
|
||||
TypeOrmModule.forFeature(
|
||||
[User, AppToken, Workspace, UserWorkspace],
|
||||
'core',
|
||||
),
|
||||
TypeORMModule,
|
||||
DataSourceModule,
|
||||
EmailModule,
|
||||
|
||||
@ -8,6 +8,7 @@ export type AuthContext = {
|
||||
apiKey?: ApiKeyWorkspaceEntity | null | undefined;
|
||||
workspaceMemberId?: string;
|
||||
workspace: Workspace;
|
||||
userWorkspaceId?: string;
|
||||
};
|
||||
|
||||
export type JwtPayload = {
|
||||
@ -16,4 +17,5 @@ export type JwtPayload = {
|
||||
workspaceMemberId?: string;
|
||||
jti?: string;
|
||||
type?: WorkspaceTokenType;
|
||||
userWorkspaceId?: string;
|
||||
};
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
|
||||
import { getRequest } from 'src/utils/extract-request';
|
||||
|
||||
export const AuthUserWorkspaceId = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = getRequest(ctx);
|
||||
|
||||
return request.userWorkspaceId;
|
||||
},
|
||||
);
|
||||
@ -27,6 +27,7 @@ export class JwtAuthGuard implements CanActivate {
|
||||
request.workspaceId = data.workspace.id;
|
||||
request.workspaceMetadataVersion = metadataVersion;
|
||||
request.workspaceMemberId = data.workspaceMemberId;
|
||||
request.userWorkspaceId = data.userWorkspaceId;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user