Skip to content

Commit

Permalink
[Permissions] Add userWorkspaceId to JWT token (#9954)
Browse files Browse the repository at this point in the history
This information will be used to fetch a user's role and check their
permissions
  • Loading branch information
ijreilly authored Jan 31, 2025
1 parent f00e7cc commit 58aa86c
Show file tree
Hide file tree
Showing 11 changed files with 124 additions and 7 deletions.
1 change: 1 addition & 0 deletions packages/twenty-server/@types/express.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ declare module 'express-serve-static-core' {
workspaceId?: string;
workspaceMetadataVersion?: number;
workspaceMemberId?: string;
userWorkspaceId?: string;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -70,6 +71,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
FeatureFlag,
WorkspaceSSOIdentityProvider,
KeyValuePair,
UserWorkspace,
],
'core',
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -11,6 +12,7 @@ describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy;

let workspaceRepository: any;
let userWorkspaceRepository: any;
let userRepository: any;
let dataSourceService: any;
let typeORMService: any;
Expand All @@ -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 = {
Expand All @@ -45,6 +51,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);

await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
Expand Down Expand Up @@ -80,6 +87,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);

await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
Expand Down Expand Up @@ -117,6 +125,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);

const result = await strategy.validate(payload as JwtPayload);
Expand Down Expand Up @@ -148,6 +157,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);

await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
Expand All @@ -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',
Expand All @@ -174,17 +184,63 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
};

userWorkspaceRepository = {
findOne: jest.fn(async () => null),
};

strategy = new JwtAuthStrategy(
{} as any,
{} as any,
typeORMService,
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(),
Expand Down Expand Up @@ -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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -63,6 +65,10 @@ describe('AccessTokenService', () => {
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(UserWorkspace, 'core'),
useClass: Repository,
},
{
provide: EmailService,
useValue: {},
Expand Down Expand Up @@ -92,6 +98,9 @@ describe('AccessTokenService', () => {
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
userWorkspaceRepository = module.get<Repository<UserWorkspace>>(
getRepositoryToken(UserWorkspace, 'core'),
);
});

it('should be defined', () => {
Expand All @@ -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';

Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type AuthContext = {
apiKey?: ApiKeyWorkspaceEntity | null | undefined;
workspaceMemberId?: string;
workspace: Workspace;
userWorkspaceId?: string;
};

export type JwtPayload = {
Expand All @@ -16,4 +17,5 @@ export type JwtPayload = {
workspaceMemberId?: string;
jti?: string;
type?: WorkspaceTokenType;
userWorkspaceId?: string;
};
Original file line number Diff line number Diff line change
@@ -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;
},
);
1 change: 1 addition & 0 deletions packages/twenty-server/src/engine/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 58aa86c

Please sign in to comment.