diff --git a/README.md b/README.md index e435fb0..0205b66 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,17 @@ Developers can customise this as per their requirement. - Clone the repo and execute command `npm install` - Create a copy of the env.sample file and rename it as .env - Install postgres and redis +- Create a restricted tenant user that cannot bypass RLS policies - Provide postgres, redis secrets and default user details in .env file as mentioned below | Database configuration(Required) | | |--|--| |POSTGRES_HOST | localhost | |POSTGRES_PORT | 5432| -|POSTGRES_USER | postgres | -|POSTGRES_PASSWORD | postgres | +|POSTGRES_ADMIN_USER | postgres | +|POSTGRES_ADMIN_PASSWORD | postgres | +|POSTGRES_TENANT_USER | tenant | +|POSTGRES_TENANT_PASSWORD | tenant | |POSTGRES_DB | auth_service |   @@ -80,6 +83,7 @@ Developers can customise this as per their requirement. |JWT_TOKEN_EXPTIME|3600 | |JWT_REFRESH_TOKEN_EXP_TIME| 36000 | |ENV | local| +|AUTH_KEY| Required authentication key for tenant creation |   | Other Configuration(Required) | | @@ -114,6 +118,12 @@ Developers can customise this as per their requirement. | OTP_WINDOW | 300 | | OTP_STEP | 1 | +   +|Multi-Tenancy Configuration(Optional) | | +|--|--| +|MULTI_TENANCY_ENABLED | A boolean that indicates if multi-tenancy is enabled, used for handling user login | +|DEFAULT_TENANT_ID | Default tenant id to be used when multi-tenancy is disabled | + - Run `npm run migration:run` - Run `npm run start` - Service should be up and running in http://localhost:${PORT}. @@ -131,4 +141,18 @@ GraphQL endpoint http://localhost:${PORT}/auth/api/graphql -[API Documentation](https://documenter.getpostman.com/view/10091423/U16ev8cG) \ No newline at end of file +[API Documentation](https://documenter.getpostman.com/view/10091423/U16ev8cG) + +## Multi-tenancy Support + +This service supports multi-tenancy with complete data isolation between tenants at the database level using PostgreSQL row-level security (RLS). Each tenant's data is isolated using a tenant_id column and RLS policies. + +### How it Works + +1. Every tenant specific entities in the system has a `tenant_id` column +2. PostgreSQL Row Level Security (RLS) policies are enabled on all tables +3. The `app.tenant_id` configuration parameter is set for each request +4. Database queries are automatically filtered by the `tenant_id` through RLS policies + +##### Setting up Database User for Multi-tenancy +Create a database user with restricted access (can be done using the provided init-db.sh) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1c1acbe..9b5d107 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,12 +19,19 @@ services: postgres: container_name: postgres image: postgres:12 + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_ADMIN_USER: ${POSTGRES_USER} + PGPASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_TENANT_USER: ${POSTGRES_TENANT_USER} + POSTGRES_TENANT_PASSWORD: ${POSTGRES_TENANT_PASSWORD} expose: - '5432' ports: - '5432:5432' volumes: - /data/postgres:/var/lib/postgresql/data + - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh env_file: - docker.env networks: diff --git a/docker.env.sample b/docker.env.sample index 27eca62..f6d2bfb 100644 --- a/docker.env.sample +++ b/docker.env.sample @@ -1,5 +1,7 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=authentication-service +POSTGRES_TENANT_USER=tenant +POSTGRES_TENANT_PASSWORD=tenant PGADMIN_DEFAULT_EMAIL= PGADMIN_DEFAULT_PASSWORD= \ No newline at end of file diff --git a/env.sample b/env.sample index 18f559c..0bde3bc 100644 --- a/env.sample +++ b/env.sample @@ -1,8 +1,13 @@ +# Superuser for migrations +POSTGRES_ADMIN_USER=postgres +POSTGRES_ADMIN_PASSWORD=postgres +# Minimal user with restricted access +POSTGRES_TENANT_USER=tenant +POSTGRES_TENANT_PASSWORD=tenant POSTGRES_HOST=localhost POSTGRES_PORT=5432 -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres POSTGRES_DB=authentication-service +POSTGRES_TENANT_MAX_CONNECTION_LIMIT=10 REDIS_HOST=localhost REDIS_PORT=6379 REDIS_CACHE_TTL=3600 @@ -32,3 +37,6 @@ MIN_RECAPTCHA_SCORE=.5 RECAPTCHA_VERIFY_URL=https://www.google.com/recaptcha/api/siteverify DEFAULT_ADMIN_PASSWORD=adminpassword INVITATION_TOKEN_EXPTIME = 7d +AUTH_KEY= +MULTI_TENANCY_ENABLED= +DEFAULT_TENANT_ID= diff --git a/init-db.sh b/init-db.sh new file mode 100644 index 0000000..b58a0c9 --- /dev/null +++ b/init-db.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +# Create the database if it doesn't exist +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB') THEN + CREATE DATABASE "$POSTGRES_DB" + WITH + OWNER = postgres + ENCODING = 'UTF8'; + END IF; + END + \$\$; +EOSQL + +# Create tenant user if it doesn't exist +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_user WHERE usename = '$POSTGRES_TENANT_USER') THEN + CREATE USER $POSTGRES_TENANT_USER WITH PASSWORD '$POSTGRES_TENANT_PASSWORD'; + END IF; + END + \$\$; + + GRANT CONNECT ON DATABASE "$POSTGRES_DB" TO "$POSTGRES_TENANT_USER"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "$POSTGRES_TENANT_USER"; + GRANT USAGE ON SCHEMA public TO "$POSTGRES_TENANT_USER"; +EOSQL + +echo "Database setup complete" diff --git a/package-lock.json b/package-lock.json index 940a51f..2a8350a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4473,6 +4473,12 @@ "integrity": "sha512-2BaZbcYqJ8jQZoIYChf/sVSdKvQOw1pqesmdUKGgwb+TBfVbFFx5mBdfL6SRPs8FBa64xep9Yp5CQDHas5b/4Q==", "dev": true }, + "@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "@types/validator": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.1.3.tgz", @@ -5005,7 +5011,7 @@ "any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" }, "anymatch": { "version": "3.1.1", @@ -5207,6 +5213,11 @@ } } }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, "ws": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", @@ -6640,7 +6651,7 @@ "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "content-disposition": { "version": "0.5.3", @@ -6952,7 +6963,7 @@ "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, "denque": { "version": "1.5.0", @@ -8669,7 +8680,7 @@ "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" }, "has-value": { "version": "1.0.0", @@ -11824,6 +11835,13 @@ "is-docker": "^2.0.0" } }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "optional": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14460,7 +14478,7 @@ "thenify-all": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", "requires": { "thenify": ">= 3.1.0 < 4" } @@ -15342,9 +15360,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", - "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==" + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, "v8-compile-cache": { "version": "2.3.0", diff --git a/package.json b/package.json index 7664161..be14bc4 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "twilio": "^3.67.1", "typeorm": "^0.3.11", "typeorm-naming-strategies": "^2.0.0", + "uuid": "^8.3.2", "winston": "^3.3.3" }, "devDependencies": { @@ -86,6 +87,7 @@ "@types/speakeasy": "^2.0.6", "@types/supertest": "^2.0.10", "@types/totp-generator": "0.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/parser": "^4.19.0", "eslint": "^7.22.0", diff --git a/src/app.module.ts b/src/app.module.ts index cdae947..68fd3d2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module } from '@nestjs/common'; import * as Joi from '@hapi/joi'; import { ConfigModule } from '@nestjs/config'; @@ -7,6 +7,7 @@ import { AppGraphQLModule } from './graphql/graphql.module'; import { UserAuthModule } from './authentication/authentication.module'; import { AuthorizationModule } from './authorization/authorization.module'; import { HealthModule } from './health/health.module'; +import { ExecutionContextBinder } from './middleware/executionContext.middleware'; @Module({ imports: [ @@ -14,8 +15,10 @@ import { HealthModule } from './health/health.module'; validationSchema: Joi.object({ POSTGRES_HOST: Joi.string().required(), POSTGRES_PORT: Joi.number().required(), - POSTGRES_USER: Joi.string().required(), - POSTGRES_PASSWORD: Joi.string().required(), + POSTGRES_ADMIN_USER: Joi.string().required(), + POSTGRES_ADMIN_PASSWORD: Joi.string().required(), + POSTGRES_TENANT_USER: Joi.string().required(), + POSTGRES_TENANT_PASSWORD: Joi.string().required(), POSTGRES_DB: Joi.string().required(), PORT: Joi.number(), JWT_SECRET: Joi.string().required().min(10), @@ -30,4 +33,8 @@ import { HealthModule } from './health/health.module'; controllers: [], providers: [], }) -export class AppModule {} +export class AppModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(ExecutionContextBinder).forRoutes('*'); + } +} diff --git a/src/authentication/authKey.guard.ts b/src/authentication/authKey.guard.ts new file mode 100644 index 0000000..4ddbf9c --- /dev/null +++ b/src/authentication/authKey.guard.ts @@ -0,0 +1,20 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +@Injectable() +export class AuthKeyGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const ctx = GqlExecutionContext.create(context).getContext(); + if (ctx) { + const authKeyInHeader = ctx.headers['x-api-key']; + if (authKeyInHeader) { + const secretKey = this.configService.get('AUTH_KEY') as string; + return secretKey === authKeyInHeader; + } + } + return false; + } +} diff --git a/src/authentication/authentication.graphql b/src/authentication/authentication.graphql index f60b274..7998213 100644 --- a/src/authentication/authentication.graphql +++ b/src/authentication/authentication.graphql @@ -1,16 +1,16 @@ type Mutation { - passwordLogin(input: UserPasswordLoginInput!): TokenResponse - passwordSignup(input: UserPasswordSignupInput!): UserSignupResponse - inviteTokenSignup(input: UserInviteTokenSignupInput): InviteTokenResponse - refreshInviteToken(id: ID!):InviteTokenResponse - setPasswordForInvite(input: UserPasswordForInviteInput): UserSignupResponse - revokeInviteToken(id: ID!): Boolean - otpLogin(input: UserOTPLoginInput!): TokenResponse - otpSignup(input: UserOTPSignupInput!): UserSignupResponse - changePassword(input: UserPasswordInput!): User - refresh(input: RefreshTokenInput!): TokenResponse - logout: String - generateOtp(input: GenerateOtpInput): String + passwordLogin(input: UserPasswordLoginInput!): TokenResponse + passwordSignup(input: UserPasswordSignupInput!): UserSignupResponse + inviteTokenSignup(input: UserInviteTokenSignupInput): InviteTokenResponse + refreshInviteToken(id: ID!): InviteTokenResponse + setPasswordForInvite(input: UserPasswordForInviteInput): UserSignupResponse + revokeInviteToken(id: ID!): Boolean + otpLogin(input: UserOTPLoginInput!): TokenResponse + otpSignup(input: UserOTPSignupInput!): UserSignupResponse + changePassword(input: UserPasswordInput!): User + refresh(input: RefreshTokenInput!): TokenResponse + logout: String + generateOtp(input: GenerateOtpInput): String } input UserPasswordSignupInput { @@ -20,6 +20,7 @@ input UserPasswordSignupInput { firstName: String! middleName: String lastName: String! + tenantDomain: String } input UserOTPSignupInput { @@ -28,16 +29,19 @@ input UserOTPSignupInput { firstName: String! middleName: String lastName: String! + tenantDomain: String } input UserPasswordLoginInput { username: String! password: String! + tenantDomain: String } input UserOTPLoginInput { username: String! otp: String! + tenantDomain: String } type TokenResponse { @@ -65,11 +69,12 @@ input RefreshTokenInput { } input GenerateOtpInput { - phone: String! + phone: String! + tenantDomain: String } -input Enable2FAInput{ - code: String! +input Enable2FAInput { + code: String! } input UserInviteTokenSignupInput { diff --git a/src/authentication/authentication.guard.ts b/src/authentication/authentication.guard.ts index 3b0bda5..c91d8da 100644 --- a/src/authentication/authentication.guard.ts +++ b/src/authentication/authentication.guard.ts @@ -1,6 +1,7 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; import { AuthenticationHelper } from './authentication.helper'; +import { TokenUtil } from './util/token.util'; @Injectable() export class AuthGuard implements CanActivate { @@ -11,7 +12,7 @@ export class AuthGuard implements CanActivate { if (ctx) { const token = ctx.headers.authorization; if (token) { - const reqAuthToken = token.split(' ')[1]; + const reqAuthToken = TokenUtil.extractToken(token); ctx.user = this.authenticationHelper.validateAuthToken(reqAuthToken); return true; } diff --git a/src/authentication/authentication.helper.ts b/src/authentication/authentication.helper.ts index 163e3e5..a3f1144 100644 --- a/src/authentication/authentication.helper.ts +++ b/src/authentication/authentication.helper.ts @@ -13,9 +13,9 @@ export class AuthenticationHelper { this.configService.get('JWT_TOKEN_EXPTIME') * 1 || 60 * 60; const secret = this.configService.get('JWT_SECRET') as string; const username = userDetails.email || userDetails.phone; - const dataStoredInToken = { username: username, + tenantId: userDetails.tenantId, sub: userDetails.id, env: this.configService.get('ENV') || 'local', }; diff --git a/src/authentication/authentication.module.ts b/src/authentication/authentication.module.ts index 3deb67b..b2b1002 100644 --- a/src/authentication/authentication.module.ts +++ b/src/authentication/authentication.module.ts @@ -33,6 +33,8 @@ import PasswordAuthService from './service/password.auth.service'; import { RecaptchaService } from './service/recaptcha.service'; import { TokenService } from './service/token.service'; import TwilioOTPService from './service/twilio.otp.service'; +import { DatabaseModule } from '../database/database.module'; +import { TenantModule } from '../tenant/tenant.module'; const providers: Provider[] = [ UserAuthResolver, @@ -74,8 +76,11 @@ const providers: Provider[] = [ AuthorizationModule, TwilioImplModule, HttpModule, + DatabaseModule, + TenantModule, ], providers, controllers: [GoogleAuthController], + exports: [AuthenticationHelper], }) export class UserAuthModule {} diff --git a/src/authentication/resolver/user.auth.resolver.ts b/src/authentication/resolver/user.auth.resolver.ts index bf02343..a69c588 100644 --- a/src/authentication/resolver/user.auth.resolver.ts +++ b/src/authentication/resolver/user.auth.resolver.ts @@ -126,7 +126,7 @@ export default class UserAuthResolver { @Mutation('generateOtp') @UsePipes(new ValidationPipe(GenerateOtpInputSchema)) async generateOtp(@Args('input') request: GenerateOtpInput) { - return this.otpAuthService.sendOTP(request.phone); + return this.otpAuthService.sendOTP(request); } // Its commented as of now. This should be part of Two factor authentication. // Implementation is not yet completed. diff --git a/src/authentication/rest.authentication.guard.ts b/src/authentication/rest.authentication.guard.ts index 7f2d045..7c6e4f2 100644 --- a/src/authentication/rest.authentication.guard.ts +++ b/src/authentication/rest.authentication.guard.ts @@ -5,6 +5,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { AuthenticationHelper } from './authentication.helper'; +import { TokenUtil } from './util/token.util'; @Injectable() export class RestAuthGuard implements CanActivate { @@ -12,9 +13,8 @@ export class RestAuthGuard implements CanActivate { public async canActivate(context: ExecutionContext): Promise { const ctx = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); - const req = await this.authenticationHelper.validateAuthToken( - ctx.headers.authorization.split(' ')[1], - ); + const reqAuthToken = TokenUtil.extractToken(ctx.headers.authorization); + const req = await this.authenticationHelper.validateAuthToken(reqAuthToken); if (!(req instanceof UnauthorizedException)) { return true; } else { diff --git a/src/authentication/service/google.service.ts b/src/authentication/service/google.service.ts index bf0e978..d7ad74d 100644 --- a/src/authentication/service/google.service.ts +++ b/src/authentication/service/google.service.ts @@ -5,11 +5,15 @@ import { GoogleUserSchema } from '../validation/user.auth.schema.validation'; import { UserServiceInterface } from '../../authorization/service/user.service.interface'; import { AuthenticationHelper } from '../authentication.helper'; import { GoogleLoginUser } from '../passport/googleStrategy'; +import { TenantServiceInterface } from '../../tenant/service/tenant.service.interface'; @Injectable() export class GoogleAuthService { constructor( - @Inject(UserServiceInterface) private userService: UserServiceInterface, + @Inject(UserServiceInterface) + private userService: UserServiceInterface, + @Inject(TenantServiceInterface) + private tenantService: TenantServiceInterface, private authenticationHelper: AuthenticationHelper, ) {} private async validateInput( @@ -26,28 +30,32 @@ export class GoogleAuthService { } async googleLogin(googleLoginInput: GoogleLoginUser) { - return this.validateInput(googleLoginInput) - .then((googleUser) => { - return this.userService.getUserDetailsByEmailOrPhone(googleUser.email); - }) - .then((existingUserDetails) => { - if (!existingUserDetails) { - const userFromInput = new User(); - return this.userService.createUser({ - ...userFromInput, - ...googleLoginInput, - origin: 'google', - }); - } else return existingUserDetails; - }) - .then((user) => { - const token = this.authenticationHelper.generateTokenForUser(user); - this.userService.updateField( - user.id, - 'refreshToken', - token.refreshToken, - ); - return token; - }); + try { + const googleUser = await this.validateInput(googleLoginInput); + await this.tenantService.setTenantIdInContext(googleUser); + + let user = await this.userService.getUserDetailsByEmailOrPhone( + googleUser.email, + ); + if (!user) { + user = await this.userService.createUser({ + ...new User(), + ...googleLoginInput, + origin: 'google', + }); + } + + const token = this.authenticationHelper.generateTokenForUser(user); + await this.userService.updateField( + user.id, + 'refreshToken', + token.refreshToken, + ); + + return token; + } catch (error) { + console.error('Google login failed:', error.message); + throw new Error('Google login failed. Please try again.'); + } } } diff --git a/src/authentication/service/otp.auth.service.ts b/src/authentication/service/otp.auth.service.ts index 5725f54..7e23531 100644 --- a/src/authentication/service/otp.auth.service.ts +++ b/src/authentication/service/otp.auth.service.ts @@ -3,6 +3,7 @@ import User from '../../authorization/entity/user.entity'; import { UserNotFoundException } from '../../authorization/exception/user.exception'; import { UserServiceInterface } from '../../authorization/service/user.service.interface'; import { + GenerateOtpInput, Status, TokenResponse, UserOTPLoginInput, @@ -16,11 +17,15 @@ import { import { Authenticatable } from '../interfaces/authenticatable'; import { OTPVerifiable } from '../interfaces/otp.verifiable'; import { TokenService } from './token.service'; +import { TenantServiceInterface } from '../../tenant/service/tenant.service.interface'; @Injectable() export default class OTPAuthService implements Authenticatable { constructor( - @Inject(UserServiceInterface) private userService: UserServiceInterface, + @Inject(UserServiceInterface) + private userService: UserServiceInterface, + @Inject(TenantServiceInterface) + private tenantService: TenantServiceInterface, private tokenService: TokenService, private otpService: OTPVerifiable, ) {} @@ -28,6 +33,7 @@ export default class OTPAuthService implements Authenticatable { async userSignup( userDetails: UserOTPSignupInput, ): Promise { + await this.tenantService.setTenantIdInContext(userDetails); const verifyObj = await this.userService.verifyDuplicateUser( userDetails.email, userDetails.phone, @@ -51,6 +57,7 @@ export default class OTPAuthService implements Authenticatable { } async userLogin(userDetails: UserOTPLoginInput): Promise { + await this.tenantService.setTenantIdInContext(userDetails); const userRecord = await this.userService.getUserDetailsByUsername( userDetails.username, userDetails.username, @@ -69,13 +76,16 @@ export default class OTPAuthService implements Authenticatable { return tokenResponse; } - async sendOTP(phoneNumber: string): Promise { - const user = await this.userService.getActiveUserByPhoneNumber(phoneNumber); + async sendOTP(generateOtpInput: GenerateOtpInput): Promise { + await this.tenantService.setTenantIdInContext(generateOtpInput); + const user = await this.userService.getActiveUserByPhoneNumber( + generateOtpInput.phone, + ); if (user && user.phone) { //Found an active user, generating OTP and sending the message to the user await this.otpService.sendOTP(user); } else { - throw new UserNotFoundException(phoneNumber); + throw new UserNotFoundException(generateOtpInput.phone); } } diff --git a/src/authentication/service/password.auth.service.ts b/src/authentication/service/password.auth.service.ts index 6dd99da..8e7c772 100644 --- a/src/authentication/service/password.auth.service.ts +++ b/src/authentication/service/password.auth.service.ts @@ -25,13 +25,20 @@ import { } from '../exception/userauth.exception'; import { Authenticatable } from '../interfaces/authenticatable'; import { TokenService } from './token.service'; +import { TENANT_CONNECTION } from '../../database/database.constants'; +import { TenantServiceInterface } from '../../tenant/service/tenant.service.interface'; +import { ExecutionManager } from '../../util/execution.manager'; @Injectable() export default class PasswordAuthService implements Authenticatable { constructor( - @Inject(UserServiceInterface) private userService: UserServiceInterface, + @Inject(UserServiceInterface) + private userService: UserServiceInterface, + @Inject(TenantServiceInterface) + private tenantService: TenantServiceInterface, private tokenService: TokenService, private authenticationHelper: AuthenticationHelper, + @Inject(TENANT_CONNECTION) private dataSource: DataSource, private configService: ConfigService, ) {} @@ -39,6 +46,7 @@ export default class PasswordAuthService implements Authenticatable { async userSignup( userDetails: UserPasswordSignupInput, ): Promise { + await this.tenantService.setTenantIdInContext(userDetails); const verifyObj = await this.userService.verifyDuplicateUser( userDetails.email, userDetails.phone, @@ -89,7 +97,7 @@ export default class PasswordAuthService implements Authenticatable { const transaction = await this.dataSource.manager.transaction(async () => { const savedUser = await this.userService.createUser(userFromInput); invitationToken = this.authenticationHelper.generateInvitationToken( - { id: savedUser.id }, + { id: savedUser.id, tenantId: ExecutionManager.getTenantId() }, this.configService.get('INVITATION_TOKEN_EXPTIME'), ); await this.userService.updateField( @@ -120,6 +128,9 @@ export default class PasswordAuthService implements Authenticatable { const verificationResponse: any = this.authenticationHelper.validateInvitationToken( passwordDetails.inviteToken, ); + await this.tenantService.setTenantIdInContext({ + tenantId: verificationResponse.tenantId, + }); const user = await this.userService.getUserById(verificationResponse.id); if (user.password) { throw new PasswordAlreadySetException(user.id); @@ -135,6 +146,7 @@ export default class PasswordAuthService implements Authenticatable { } async userLogin(userDetails: UserPasswordLoginInput): Promise { + await this.tenantService.setTenantIdInContext(userDetails); const userRecord = await this.userService.getUserDetailsByUsername( userDetails.username, userDetails.username, diff --git a/src/authentication/util/token.util.ts b/src/authentication/util/token.util.ts new file mode 100644 index 0000000..32ed5af --- /dev/null +++ b/src/authentication/util/token.util.ts @@ -0,0 +1,5 @@ +export class TokenUtil { + public static extractToken(authorization: string): string { + return authorization?.split(' ')[1]; + } +} diff --git a/src/authentication/validation/user.auth.schema.validation.ts b/src/authentication/validation/user.auth.schema.validation.ts index 18a7fd5..915bca2 100644 --- a/src/authentication/validation/user.auth.schema.validation.ts +++ b/src/authentication/validation/user.auth.schema.validation.ts @@ -1,4 +1,8 @@ import * as Joi from '@hapi/joi'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +const multiTenancyEnabled = Boolean(process.env.MULTI_TENANCY_ENABLED); export const UserPasswordSignupInputSchema = Joi.object({ email: Joi.string().email({ tlds: { allow: false } }), @@ -13,6 +17,13 @@ export const UserPasswordSignupInputSchema = Joi.object({ lastName: Joi.string() .regex(/^[a-zA-Z ]*$/) .required(), + tenantDomain: Joi.string().when('email', { + is: Joi.exist(), + then: Joi.optional(), + otherwise: multiTenancyEnabled + ? Joi.string().required() + : Joi.string().optional(), + }), }) .options({ abortEarly: false }) .or('email', 'phone'); @@ -27,16 +38,29 @@ export const UserOTPSignupInputSchema = Joi.object({ lastName: Joi.string() .regex(/^[a-zA-Z ]*$/) .required(), + tenantDomain: Joi.string().when('email', { + is: Joi.exist(), + then: Joi.optional(), + otherwise: multiTenancyEnabled + ? Joi.string().required() + : Joi.string().optional(), + }), }).options({ abortEarly: false }); export const UserPasswordLoginInputSchema = Joi.object({ username: Joi.string().required(), password: Joi.string().min(10).required(), + tenantDomain: multiTenancyEnabled + ? Joi.string().required() + : Joi.string().optional(), }).options({ abortEarly: false }); export const UserOTPLoginInputSchema = Joi.object({ username: Joi.string().required(), otp: Joi.string().required(), + tenantDomain: multiTenancyEnabled + ? Joi.string().required() + : Joi.string().optional(), }).options({ abortEarly: false }); export const UserSendOTPInputSchema = Joi.object({ @@ -67,6 +91,9 @@ export const GoogleUserSchema = Joi.object({ export const GenerateOtpInputSchema = Joi.object({ phone: Joi.string().trim().required(), + tenantDomain: multiTenancyEnabled + ? Joi.string().required() + : Joi.string().optional(), }); export const Enable2FAInputSchema = Joi.object({ diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts index 99f89df..77e7019 100644 --- a/src/authorization/authorization.module.ts +++ b/src/authorization/authorization.module.ts @@ -51,6 +51,8 @@ import { UserService } from './service/user.service'; import { UserServiceInterface } from './service/user.service.interface'; import { UserCacheService } from './service/usercache.service'; import { UserCacheServiceInterface } from './service/usercache.service.interface'; +import { TenantModule } from '../tenant/tenant.module'; +import { DatabaseModule } from '../database/database.module'; @Module({ imports: [ @@ -67,7 +69,9 @@ import { UserCacheServiceInterface } from './service/usercache.service.interface GroupRole, RolePermission, ]), + TenantModule, RedisCacheModule, + DatabaseModule, ], providers: [ GroupResolver, diff --git a/src/authorization/entity/abstract.tenant.entity.ts b/src/authorization/entity/abstract.tenant.entity.ts new file mode 100644 index 0000000..7525038 --- /dev/null +++ b/src/authorization/entity/abstract.tenant.entity.ts @@ -0,0 +1,12 @@ +import { Column } from 'typeorm'; +import BaseEntity from './base.entity'; + +class AbstractTenantEntity extends BaseEntity { + @Column({ + type: 'uuid', + default: () => "current_setting('app.tenant_id')::uuid", + }) + public tenantId!: string; +} + +export default AbstractTenantEntity; diff --git a/src/authorization/entity/entity.entity.ts b/src/authorization/entity/entity.entity.ts index 51749e4..6c70a9a 100644 --- a/src/authorization/entity/entity.entity.ts +++ b/src/authorization/entity/entity.entity.ts @@ -1,9 +1,9 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() @Index('entity_name_unique_idx', { synchronize: false }) -class EntityModel extends BaseEntity { +class EntityModel extends AbstractTenantEntity { @PrimaryGeneratedColumn('uuid') public id!: string; diff --git a/src/authorization/entity/entityPermission.entity.ts b/src/authorization/entity/entityPermission.entity.ts index a3f08ed..fabdcaf 100644 --- a/src/authorization/entity/entityPermission.entity.ts +++ b/src/authorization/entity/entityPermission.entity.ts @@ -1,8 +1,8 @@ import { Entity, PrimaryColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() -class EntityPermission extends BaseEntity { +class EntityPermission extends AbstractTenantEntity { @PrimaryColumn({ type: 'uuid' }) public permissionId!: string; diff --git a/src/authorization/entity/group.entity.ts b/src/authorization/entity/group.entity.ts index 4e6ab36..efa31cf 100644 --- a/src/authorization/entity/group.entity.ts +++ b/src/authorization/entity/group.entity.ts @@ -1,9 +1,9 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() @Index('group_name_unique_idx', { synchronize: false }) -class Group extends BaseEntity { +class Group extends AbstractTenantEntity { @PrimaryGeneratedColumn('uuid') public id!: string; diff --git a/src/authorization/entity/groupPermission.entity.ts b/src/authorization/entity/groupPermission.entity.ts index 93b9f3b..7481125 100644 --- a/src/authorization/entity/groupPermission.entity.ts +++ b/src/authorization/entity/groupPermission.entity.ts @@ -1,8 +1,8 @@ import { Entity, PrimaryColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() -class GroupPermission extends BaseEntity { +class GroupPermission extends AbstractTenantEntity { @PrimaryColumn({ type: 'uuid' }) public permissionId!: string; diff --git a/src/authorization/entity/groupRole.entity.ts b/src/authorization/entity/groupRole.entity.ts index d0dd1ed..8104d22 100644 --- a/src/authorization/entity/groupRole.entity.ts +++ b/src/authorization/entity/groupRole.entity.ts @@ -1,8 +1,8 @@ import { Entity, PrimaryColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() -class GroupRole extends BaseEntity { +class GroupRole extends AbstractTenantEntity { @PrimaryColumn({ type: 'uuid' }) public roleId!: string; diff --git a/src/authorization/entity/role.entity.ts b/src/authorization/entity/role.entity.ts index 6e62937..e34bd2c 100644 --- a/src/authorization/entity/role.entity.ts +++ b/src/authorization/entity/role.entity.ts @@ -1,9 +1,9 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() @Index('role_name_unique_idx', { synchronize: false }) -class Role extends BaseEntity { +class Role extends AbstractTenantEntity { @PrimaryGeneratedColumn('uuid') public id!: string; diff --git a/src/authorization/entity/rolePermission.entity.ts b/src/authorization/entity/rolePermission.entity.ts index adc75d6..7ae285d 100644 --- a/src/authorization/entity/rolePermission.entity.ts +++ b/src/authorization/entity/rolePermission.entity.ts @@ -1,8 +1,8 @@ import { Entity, PrimaryColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() -class RolePermission extends BaseEntity { +class RolePermission extends AbstractTenantEntity { @PrimaryColumn({ type: 'uuid' }) public permissionId!: string; diff --git a/src/authorization/entity/user.entity.ts b/src/authorization/entity/user.entity.ts index 0da6209..89ea1ed 100644 --- a/src/authorization/entity/user.entity.ts +++ b/src/authorization/entity/user.entity.ts @@ -1,6 +1,6 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { Status } from '../../schema/graphql.schema'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() @Index('user_phone_unique_idx', ['phone'], { @@ -8,7 +8,7 @@ import BaseEntity from './base.entity'; where: '"deleted_at" IS NULL', }) @Index('user_email_unique_idx', { synchronize: false }) -class User extends BaseEntity { +class User extends AbstractTenantEntity { @PrimaryGeneratedColumn('uuid') public id!: string; diff --git a/src/authorization/entity/userGroup.entity.ts b/src/authorization/entity/userGroup.entity.ts index 41fef17..c88608a 100644 --- a/src/authorization/entity/userGroup.entity.ts +++ b/src/authorization/entity/userGroup.entity.ts @@ -1,7 +1,8 @@ import { Entity, PrimaryColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; + @Entity() -class UserGroup extends BaseEntity { +class UserGroup extends AbstractTenantEntity { @PrimaryColumn({ type: 'uuid' }) public groupId!: string; diff --git a/src/authorization/entity/userPermission.entity.ts b/src/authorization/entity/userPermission.entity.ts index 97788a3..bebea6d 100644 --- a/src/authorization/entity/userPermission.entity.ts +++ b/src/authorization/entity/userPermission.entity.ts @@ -1,8 +1,8 @@ import { Entity, PrimaryColumn } from 'typeorm'; -import BaseEntity from './base.entity'; +import AbstractTenantEntity from './abstract.tenant.entity'; @Entity() -class UserPermission extends BaseEntity { +class UserPermission extends AbstractTenantEntity { @PrimaryColumn({ type: 'uuid' }) public permissionId!: string; diff --git a/src/authorization/repository/entity.repository.ts b/src/authorization/repository/entity.repository.ts index d050de7..21bd8a9 100644 --- a/src/authorization/repository/entity.repository.ts +++ b/src/authorization/repository/entity.repository.ts @@ -1,12 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { UpdateEntityInput } from 'src/schema/graphql.schema'; import { DataSource } from 'typeorm'; import EntityModel from '../entity/entity.entity'; import { BaseRepository } from './base.repository'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class EntityModelRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor( + @Inject(TENANT_CONNECTION) + private dataSource: DataSource, + ) { super(EntityModel, dataSource); } diff --git a/src/authorization/repository/group.repository.ts b/src/authorization/repository/group.repository.ts index 0e7293e..814f67b 100644 --- a/src/authorization/repository/group.repository.ts +++ b/src/authorization/repository/group.repository.ts @@ -1,12 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In } from 'typeorm'; import Group from '../entity/group.entity'; import UserGroup from '../entity/userGroup.entity'; import { BaseRepository } from './base.repository'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class GroupRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor( + @Inject(TENANT_CONNECTION) + private dataSource: DataSource, + ) { super(Group, dataSource); } diff --git a/src/authorization/repository/groupRole.repository.ts b/src/authorization/repository/groupRole.repository.ts index a05e46f..c5d4a9d 100644 --- a/src/authorization/repository/groupRole.repository.ts +++ b/src/authorization/repository/groupRole.repository.ts @@ -1,12 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import Group from '../entity/group.entity'; import GroupRole from '../entity/groupRole.entity'; import { BaseRepository } from './base.repository'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class GroupRoleRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor( + @Inject(TENANT_CONNECTION) + private dataSource: DataSource, + ) { super(GroupRole, dataSource); } diff --git a/src/authorization/repository/role.repository.ts b/src/authorization/repository/role.repository.ts index 0b38ec1..3d9e002 100644 --- a/src/authorization/repository/role.repository.ts +++ b/src/authorization/repository/role.repository.ts @@ -1,13 +1,17 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { UpdateRoleInput } from 'src/schema/graphql.schema'; import { DataSource, In } from 'typeorm'; import GroupRole from '../entity/groupRole.entity'; import Role from '../entity/role.entity'; import { BaseRepository } from './base.repository'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class RoleRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor( + @Inject(TENANT_CONNECTION) + private dataSource: DataSource, + ) { super(Role, dataSource); } diff --git a/src/authorization/repository/user.repository.ts b/src/authorization/repository/user.repository.ts index 0a2d3a6..8769721 100644 --- a/src/authorization/repository/user.repository.ts +++ b/src/authorization/repository/user.repository.ts @@ -1,13 +1,17 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { UpdateUserInput } from 'src/schema/graphql.schema'; import { DataSource, In } from 'typeorm'; import User from '../entity/user.entity'; import UserGroup from '../entity/userGroup.entity'; import { BaseRepository } from './base.repository'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class UserRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor( + @Inject(TENANT_CONNECTION) + private dataSource: DataSource, + ) { super(User, dataSource); } diff --git a/src/authorization/repository/userPermission.repository.ts b/src/authorization/repository/userPermission.repository.ts index c882b9b..cbaa187 100644 --- a/src/authorization/repository/userPermission.repository.ts +++ b/src/authorization/repository/userPermission.repository.ts @@ -1,11 +1,15 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import UserPermission from '../entity/userPermission.entity'; import { BaseRepository } from './base.repository'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class UserPermissionRepository extends BaseRepository { - constructor(private dataSource: DataSource) { + constructor( + @Inject(TENANT_CONNECTION) + private dataSource: DataSource, + ) { super(UserPermission, dataSource); } diff --git a/src/authorization/resolver/entity.resolver.ts b/src/authorization/resolver/entity.resolver.ts index f465e7f..1e423aa 100644 --- a/src/authorization/resolver/entity.resolver.ts +++ b/src/authorization/resolver/entity.resolver.ts @@ -52,7 +52,7 @@ export class EntityResolver { async updateEntityPermissions( @Args('id', ParseUUIDPipe) id: string, @Args('input') entityInput: UpdateEntityPermissionInput, - ): Promise { + ): Promise { return this.entityService.updateEntityPermissions(id, entityInput); } diff --git a/src/authorization/service/entity.service.ts b/src/authorization/service/entity.service.ts index d5327f7..9344b52 100644 --- a/src/authorization/service/entity.service.ts +++ b/src/authorization/service/entity.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { NewEntityInput, @@ -14,6 +14,8 @@ import { EntityModelRepository } from '../repository/entity.repository'; import { EntityPermissionRepository } from '../repository/entityPermission.repository'; import { PermissionRepository } from '../repository/permission.repository'; import { EntityServiceInterface } from './entity.service.interface'; +import { ExecutionManager } from '../../util/execution.manager'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class EntityService implements EntityServiceInterface { @@ -21,6 +23,7 @@ export class EntityService implements EntityServiceInterface { private entityRepository: EntityModelRepository, private entityPermissionRepository: EntityPermissionRepository, private permissionRepository: PermissionRepository, + @Inject(TENANT_CONNECTION) private dataSource: DataSource, ) {} @@ -60,7 +63,6 @@ export class EntityService implements EntityServiceInterface { if (!existingEntity) { throw new EntityNotFoundException(id); } - await this.dataSource.manager.transaction(async (entityManager) => { const entityRepo = entityManager.getRepository(EntityModel); const entityPermissionRepo = entityManager.getRepository( @@ -100,9 +102,10 @@ export class EntityService implements EntityServiceInterface { ); } + const tenantId = ExecutionManager.getTenantId(); const permissionsToBeRemovedFromEntity: EntityPermission[] = existingPermissionsOfEntity .filter((p) => !validPermissionsInRequest.has(p.id)) - .map((p) => ({ permissionId: p.id, entityId: id })); + .map((p) => ({ permissionId: p.id, entityId: id, tenantId })); const entityPermission = this.entityPermissionRepository.create( request.permissions.map((permission) => ({ diff --git a/src/authorization/service/group.service.ts b/src/authorization/service/group.service.ts index fc062ea..0f9be29 100644 --- a/src/authorization/service/group.service.ts +++ b/src/authorization/service/group.service.ts @@ -41,6 +41,8 @@ import SearchService from './search.service'; import { UserCacheServiceInterface } from './usercache.service.interface'; import { DUPLICATE_ERROR_CODE } from '../../constants/db.error.constants'; import { LoggerService } from '../../logger/logger.service'; +import { ExecutionManager } from '../../util/execution.manager'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class GroupService implements GroupServiceInterface { @@ -52,6 +54,7 @@ export class GroupService implements GroupServiceInterface { private groupRoleRepository: GroupRoleRepository, private rolesRepository: RoleRepository, private userRepository: UserRepository, + @Inject(TENANT_CONNECTION) private dataSource: DataSource, @Inject(GroupCacheServiceInterface) private groupCacheService: GroupCacheServiceInterface, @@ -209,9 +212,10 @@ export class GroupService implements GroupServiceInterface { ); } + const tenantId = ExecutionManager.getTenantId(); const permissionsToBeRemovedFromGroup: GroupPermission[] = existingPermissionsOfGroup .filter((p) => !validPermissionsInRequest.has(p.id)) - .map((p) => ({ permissionId: p.id, groupId: id })); + .map((p) => ({ permissionId: p.id, groupId: id, tenantId })); const groupPermission = this.groupPermissionRepository.create( request.permissions.map((permission) => ({ @@ -241,9 +245,10 @@ export class GroupService implements GroupServiceInterface { const validUsersInRequest = await this.validateUsers(userIds); const existingUsersOfGroup = await this.getGroupUsers(id); + const tenantId = ExecutionManager.getTenantId(); const usersToBeRemovedFromGroup: UserGroup[] = existingUsersOfGroup .filter((user) => !validUsersInRequest.has(user.id)) - .map((user) => ({ userId: user.id, groupId: id })); + .map((user) => ({ userId: user.id, groupId: id, tenantId })); const userGroups = this.userGroupRepository.create( userIds.map((userId) => ({ userId: userId, groupId: id })), ); @@ -306,9 +311,10 @@ export class GroupService implements GroupServiceInterface { ); } + const tenantId = ExecutionManager.getTenantId(); const rolesToBeRemovedFromGroup: GroupRole[] = existingRolesOfGroup .filter((p) => !validRolesInRequest.has(p.id)) - .map((r) => ({ groupId: id, roleId: r.id })); + .map((r) => ({ groupId: id, roleId: r.id, tenantId })); const groupRoles = this.groupRoleRepository.create( request.roles.map((role) => ({ diff --git a/src/authorization/service/role.service.ts b/src/authorization/service/role.service.ts index bc6bfce..d0dec5e 100644 --- a/src/authorization/service/role.service.ts +++ b/src/authorization/service/role.service.ts @@ -23,7 +23,8 @@ import { RolePermissionRepository } from '../repository/rolePermission.repositor import { RoleServiceInterface } from './role.service.interface'; import { RoleCacheServiceInterface } from './rolecache.service.interface'; import SearchService from './search.service'; - +import { ExecutionManager } from '../../util/execution.manager'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class RoleService implements RoleServiceInterface { constructor( @@ -33,6 +34,7 @@ export class RoleService implements RoleServiceInterface { private groupRoleRepository: GroupRoleRepository, private rolePermissionRepository: RolePermissionRepository, private permissionRepository: PermissionRepository, + @Inject(TENANT_CONNECTION) private dataSource: DataSource, private searchService: SearchService, ) {} @@ -142,9 +144,10 @@ export class RoleService implements RoleServiceInterface { ); } + const tenantId = ExecutionManager.getTenantId(); const permissionsToBeRemovedFromRole: RolePermission[] = existingPermissionsOfRole .filter((p) => !validPermissionsInRequest.has(p.id)) - .map((p) => ({ permissionId: p.id, roleId: id })); + .map((p) => ({ permissionId: p.id, roleId: id, tenantId })); const rolePermissions = this.rolePermissionRepository.create( request.permissions.map((permission) => ({ diff --git a/src/authorization/service/user.service.ts b/src/authorization/service/user.service.ts index a974230..e7984bb 100644 --- a/src/authorization/service/user.service.ts +++ b/src/authorization/service/user.service.ts @@ -32,6 +32,8 @@ import { RoleCacheServiceInterface } from './rolecache.service.interface'; import SearchService from './search.service'; import { UserServiceInterface } from './user.service.interface'; import { UserCacheServiceInterface } from './usercache.service.interface'; +import { ExecutionManager } from '../../util/execution.manager'; +import { TENANT_CONNECTION } from '../../database/database.constants'; @Injectable() export class UserService implements UserServiceInterface { @@ -47,13 +49,14 @@ export class UserService implements UserServiceInterface { private groupCacheService: GroupCacheServiceInterface, @Inject(PermissionCacheServiceInterface) private permissionCacheService: PermissionCacheServiceInterface, + @Inject(TENANT_CONNECTION) private dataSource: DataSource, private searchService: SearchService, @Inject(RoleCacheServiceInterface) private roleCacheService: RoleCacheServiceInterface, ) {} - getAllUsers(input?: UserInputFilter): Promise<[User[], number]> { + async getAllUsers(input?: UserInputFilter): Promise<[User[], number]> { const SortFieldMapping = new Map([ ['firstName', 'user.firstName'], ['updatedAt', 'user.updated_at'], @@ -147,9 +150,10 @@ export class UserService implements UserServiceInterface { ); } + const tenantId = ExecutionManager.getTenantId(); const groupsToBeRemovedFromUser: UserGroup[] = existingGroupsOfUser .filter((p) => !validGroupsInRequest.has(p.id)) - .map((g) => ({ userId: id, groupId: g.id })); + .map((g) => ({ userId: id, groupId: g.id, tenantId })); const userGroups = this.userGroupRepository.create( user.groups.map((group) => ({ userId: id, groupId: group })), ); @@ -186,10 +190,10 @@ export class UserService implements UserServiceInterface { request.permissions.filter((p) => !validPermissions.has(p)).toString(), ); } - + const tenantId = ExecutionManager.getTenantId(); const userPermissionsToBeRemoved: UserPermission[] = existingUserPermissions .filter((p) => !validPermissions.has(p.id)) - .map((p) => ({ userId: id, permissionId: p.id })); + .map((p) => ({ userId: id, permissionId: p.id, tenantId })); this.userPermissionRepository.remove(userPermissionsToBeRemoved); const userPermissionsCreated = this.userPermissionRepository.create( @@ -199,7 +203,7 @@ export class UserService implements UserServiceInterface { })), ); - const userPermissionsUpdated = await this.dataSource.transaction( + const userPermissionsUpdated = await this.dataSource.manager.transaction( async (entityManager) => { const userPermissionsRepo = entityManager.getRepository(UserPermission); await userPermissionsRepo.remove(userPermissionsToBeRemoved); diff --git a/src/config/migration.config.ts b/src/config/migration.config.ts index 34b3a79..6b338ce 100644 --- a/src/config/migration.config.ts +++ b/src/config/migration.config.ts @@ -1,6 +1,6 @@ import { DataSource } from 'typeorm'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; -import dotenv from 'dotenv'; +import * as dotenv from 'dotenv'; dotenv.config(); /** * Uses env params to configure TypeORM database library @@ -10,8 +10,8 @@ const dataSource = new DataSource({ host: process.env.POSTGRES_HOST, port: parseInt(process.env.POSTGRES_PORT as string), database: process.env.POSTGRES_DB, - username: process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASSWORD, + username: process.env.POSTGRES_ADMIN_USER, + password: process.env.POSTGRES_ADMIN_PASSWORD, synchronize: false, logging: false, namingStrategy: new SnakeNamingStrategy(), diff --git a/src/database/database.constants.ts b/src/database/database.constants.ts new file mode 100644 index 0000000..03a06c4 --- /dev/null +++ b/src/database/database.constants.ts @@ -0,0 +1 @@ +export const TENANT_CONNECTION = 'TENANT_CONNECTION'; diff --git a/src/database/database.module.ts b/src/database/database.module.ts index 153dfe2..ec48018 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; +import { databaseProvider } from './database.provider'; @Module({ imports: [ @@ -12,8 +13,8 @@ import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; type: 'postgres', host: configService.get('POSTGRES_HOST'), port: configService.get('POSTGRES_PORT'), - username: configService.get('POSTGRES_USER'), - password: configService.get('POSTGRES_PASSWORD'), + username: configService.get('POSTGRES_ADMIN_USER'), + password: configService.get('POSTGRES_ADMIN_PASSWORD'), database: configService.get('POSTGRES_DB'), entities: [ __dirname + '/../**/*.entity.ts', @@ -25,5 +26,7 @@ import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; }), }), ], + exports: [databaseProvider], + providers: [databaseProvider], }) export class DatabaseModule {} diff --git a/src/database/database.provider.ts b/src/database/database.provider.ts new file mode 100644 index 0000000..c90a6a2 --- /dev/null +++ b/src/database/database.provider.ts @@ -0,0 +1,68 @@ +import { Scope } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { DataSource, getConnectionManager } from 'typeorm'; +import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; +import { ExecutionManager } from '../util/execution.manager'; +import { TENANT_CONNECTION } from './database.constants'; +import { LoggerService } from 'src/logger/logger.service'; + +export const databaseProvider = { + provide: TENANT_CONNECTION, + scope: Scope.REQUEST, + useFactory: async () => { + const tenantId = ExecutionManager.getTenantId(); + + if (getConnectionManager().has(tenantId)) { + const con = getConnectionManager().get(tenantId); + const existingConnection = await Promise.resolve( + con.isConnected ? con : con.connect(), + ); + await switchToTenant(tenantId, existingConnection); + + return existingConnection; + } + + const newConnection: DataSource = await new DataSource({ + name: tenantId, + type: 'postgres', + host: process.env.POSTGRES_HOST, + port: Number(process.env.POSTGRES_PORT), + username: process.env.POSTGRES_TENANT_USER, + password: process.env.POSTGRES_TENANT_PASSWORD, + database: process.env.POSTGRES_DB, + entities: [ + __dirname + '/../**/*.entity.ts', + __dirname + '/../**/*.entity.js', + ], + synchronize: false, + logging: ['error'], + namingStrategy: new SnakeNamingStrategy(), + ...(process.env.POSTGRES_TENANT_MAX_CONNECTION_LIMIT + ? { extra: { max: process.env.POSTGRES_TENANT_MAX_CONNECTION_LIMIT } } + : {}), + }).initialize(); + + await switchToTenant(tenantId, newConnection); + + return newConnection; + }, + inject: [REQUEST], +}; + +const switchToTenant = async ( + tenantId: string, + connection: DataSource, +): Promise => { + try { + await connection.query(`select set_config('app.tenant_id', $1, false)`, [ + tenantId, + ]); + } catch (error) { + const logger = LoggerService.getInstance('bootstrap()'); + logger.error( + `Failed to switch to tenant: ${tenantId}, error: ${JSON.stringify( + error, + )}]`, + ); + } +}; diff --git a/src/main.ts b/src/main.ts index 6303653..5789d72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { ValidationPipe } from '@nestjs/common'; import { CustomExceptionsFilter } from './exception/exception.filter'; import { ConfigService } from '@nestjs/config'; import { LoggerService } from './logger/logger.service'; +import { ExecutionManager } from './util/execution.manager'; async function bootstrap() { const appOptions = { cors: true }; @@ -15,6 +16,8 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe()); app.useGlobalFilters(new CustomExceptionsFilter()); + ExecutionManager.init(); + await app.listen(configService.get('PORT') || 4000); logger.info(`Application is running on: ${await app.getUrl()}`); diff --git a/src/middleware/executionContext.middleware.ts b/src/middleware/executionContext.middleware.ts new file mode 100644 index 0000000..18e99b7 --- /dev/null +++ b/src/middleware/executionContext.middleware.ts @@ -0,0 +1,25 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { AuthenticationHelper } from '../authentication/authentication.helper'; +import { ExecutionManager } from '../util/execution.manager'; +import { TokenUtil } from '../authentication/util/token.util'; + +@Injectable() +export class ExecutionContextBinder implements NestMiddleware { + constructor(private readonly auth: AuthenticationHelper) {} + async use(req: Request, res: Response, next: NextFunction) { + ExecutionManager.runWithContext(async () => { + try { + const token = req.headers.authorization; + if (token) { + const reqAuthToken = TokenUtil.extractToken(token); + const user = this.auth.validateAuthToken(reqAuthToken); + ExecutionManager.setTenantId(user.tenantId); + } + next(); + } catch (error) { + next(); + } + }); + } +} diff --git a/src/migrations/1733833844028-MultiTenantFeature.ts b/src/migrations/1733833844028-MultiTenantFeature.ts new file mode 100644 index 0000000..491b09a --- /dev/null +++ b/src/migrations/1733833844028-MultiTenantFeature.ts @@ -0,0 +1,178 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MultiTenantFeature1733833844028 implements MigrationInterface { + name = 'MultiTenantFeature1733833844028'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "tenant" ("deleted_at" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "domain" character varying NOT NULL, CONSTRAINT "UQ_97b9c4dae58b30f5bd875f241ab" UNIQUE ("domain"), CONSTRAINT "PK_da8c6efd67bb301e810e56ac139" PRIMARY KEY ("id"))`, + ); + const tenants = await queryRunner.query( + `INSERT INTO "tenant" ("name", "domain") VALUES ('Default Tenant', 'default.domain') RETURNING id`, + ); + const tenantId = tenants[0].id; + await queryRunner.query( + `ALTER TABLE "entity_model" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "entity_model" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "entity_permission" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "entity_permission" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "group" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "group" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "group_permission" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "group_permission" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "group_role" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "group_role" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "role" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "role" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "role_permission" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "role_permission" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "user_permission" ADD "tenant_id" uuid NOT NULL DEFAULT '${tenantId}'`, + ); + await queryRunner.query( + `ALTER TABLE "user_permission" ALTER COLUMN "tenant_id" DROP DEFAULT`, + ); + await queryRunner.query(` + ALTER TABLE "user" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "role" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "group" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "entity_model" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "user_group" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "user_permission" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "group_role" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "group_permission" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "role_permission" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "entity_permission" ENABLE ROW LEVEL SECURITY; + + CREATE POLICY tenant_isolation_policy ON "user" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "role" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "group" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "entity_model" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "user_group" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "user_permission" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "group_role" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "group_permission" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "role_permission" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + CREATE POLICY tenant_isolation_policy ON "entity_permission" + USING (tenant_id = current_setting('app.tenant_id')::uuid); + `); + await queryRunner.query(` + ALTER TABLE "entity_permission" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "group" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "group_permission" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "entity_model" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "role" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "group_role" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "role_permission" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "user" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "user_group" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + ALTER TABLE "user_permission" ALTER COLUMN "tenant_id" SET DEFAULT current_setting('app.tenant_id')::uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user_permission" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "user_group" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "user" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "role_permission" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "group_role" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "role" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "entity_model" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "group_permission" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "group" ALTER COLUMN "tenant_id" DROP DEFAULT; + ALTER TABLE "entity_permission" ALTER COLUMN "tenant_id" DROP DEFAULT; + `); + await queryRunner.query(` + DROP POLICY tenant_isolation_policy ON "user"; + DROP POLICY tenant_isolation_policy ON "role"; + DROP POLICY tenant_isolation_policy ON "group"; + DROP POLICY tenant_isolation_policy ON "entity_model"; + DROP POLICY tenant_isolation_policy ON "user_group"; + DROP POLICY tenant_isolation_policy ON "user_permission"; + DROP POLICY tenant_isolation_policy ON "group_role"; + DROP POLICY tenant_isolation_policy ON "group_permission"; + DROP POLICY tenant_isolation_policy ON "role_permission"; + DROP POLICY tenant_isolation_policy ON "entity_permission"; + ALTER TABLE "user" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "role" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "group" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "entity_model" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "user_group" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "user_permission" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "group_role" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "group_permission" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "role_permission" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "entity_permission" DISABLE ROW LEVEL SECURITY; + `); + await queryRunner.query( + `ALTER TABLE "user_permission" DROP COLUMN "tenant_id"`, + ); + await queryRunner.query(`ALTER TABLE "user_group" DROP COLUMN "tenant_id"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "tenant_id"`); + await queryRunner.query( + `ALTER TABLE "role_permission" DROP COLUMN "tenant_id"`, + ); + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "tenant_id"`); + await queryRunner.query(`ALTER TABLE "group_role" DROP COLUMN "tenant_id"`); + await queryRunner.query( + `ALTER TABLE "group_permission" DROP COLUMN "tenant_id"`, + ); + await queryRunner.query(`ALTER TABLE "group" DROP COLUMN "tenant_id"`); + await queryRunner.query( + `ALTER TABLE "entity_permission" DROP COLUMN "tenant_id"`, + ); + await queryRunner.query( + `ALTER TABLE "entity_model" DROP COLUMN "tenant_id"`, + ); + await queryRunner.query(`DROP TABLE "tenant"`); + } +} diff --git a/src/schema/graphql.schema.ts b/src/schema/graphql.schema.ts index f3d4a9a..a108901 100644 --- a/src/schema/graphql.schema.ts +++ b/src/schema/graphql.schema.ts @@ -35,6 +35,7 @@ export interface UserPasswordSignupInput { firstName: string; middleName?: string; lastName: string; + tenantDomain?: string; } export interface UserOTPSignupInput { @@ -43,16 +44,19 @@ export interface UserOTPSignupInput { firstName: string; middleName?: string; lastName: string; + tenantDomain?: string; } export interface UserPasswordLoginInput { username: string; password: string; + tenantDomain?: string; } export interface UserOTPLoginInput { username: string; otp: string; + tenantDomain?: string; } export interface UserPasswordInput { @@ -66,6 +70,7 @@ export interface RefreshTokenInput { export interface GenerateOtpInput { phone: string; + tenantDomain?: string; } export interface Enable2FAInput { @@ -228,6 +233,11 @@ export interface PaginationInput { offset?: number; } +export interface NewTenantInput { + name: string; + domain: string; +} + export interface Paginated { totalCount?: number; } @@ -265,6 +275,7 @@ export interface IMutation { deleteUser(id: string): User | Promise; updateUserPermissions(id: string, input: UpdateUserPermissionInput): Permission[] | Promise; updateUserGroups(id: string, input: UpdateUserGroupInput): UserGroupResponse[] | Promise; + createTenant(input: NewTenantInput): Tenant | Promise; } export interface TokenResponse { @@ -373,3 +384,9 @@ export interface UserGroupResponse { id: string; name: string; } + +export interface Tenant { + id: string; + name: string; + domain: string; +} diff --git a/src/tenant/entity/tenant.entity.ts b/src/tenant/entity/tenant.entity.ts new file mode 100644 index 0000000..04f136a --- /dev/null +++ b/src/tenant/entity/tenant.entity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import BaseEntity from '../../authorization/entity/base.entity'; + +@Entity() +class Tenant extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + public id!: string; + + @Column() + public name!: string; + + @Column({ unique: true }) + public domain!: string; +} + +export default Tenant; diff --git a/src/tenant/exception/tenant.exception.ts b/src/tenant/exception/tenant.exception.ts new file mode 100644 index 0000000..829924e --- /dev/null +++ b/src/tenant/exception/tenant.exception.ts @@ -0,0 +1,7 @@ +import { NotFoundException } from '@nestjs/common'; + +export class TenantNotFoundException extends NotFoundException { + constructor(tenantDomain: string) { + super(`Tenant with domain ${tenantDomain} not found`); + } +} diff --git a/src/tenant/graphql/tenant.graphql b/src/tenant/graphql/tenant.graphql new file mode 100644 index 0000000..a362b0f --- /dev/null +++ b/src/tenant/graphql/tenant.graphql @@ -0,0 +1,14 @@ +input NewTenantInput { + name: String! + domain: String! +} + +type Tenant { + id: ID! + name: String! + domain: String! +} + +type Mutation { + createTenant(input: NewTenantInput!): Tenant +} diff --git a/src/tenant/repository/tenant.repository.ts b/src/tenant/repository/tenant.repository.ts new file mode 100644 index 0000000..800b01e --- /dev/null +++ b/src/tenant/repository/tenant.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { BaseRepository } from '../../authorization/repository/base.repository'; +import Tenant from '../entity/tenant.entity'; + +@Injectable() +export class TenantRepository extends BaseRepository { + constructor(private dataSource: DataSource) { + super(Tenant, dataSource); + } + + async getTenantByDomain(domain: string) { + return this.findOneBy({ domain }); + } +} diff --git a/src/tenant/resolver/tenant.resolver.ts b/src/tenant/resolver/tenant.resolver.ts new file mode 100644 index 0000000..f29af9d --- /dev/null +++ b/src/tenant/resolver/tenant.resolver.ts @@ -0,0 +1,22 @@ +import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { NewTenantInput } from '../../schema/graphql.schema'; +import Tenant from '../entity/tenant.entity'; +import { Inject, UseGuards } from '@nestjs/common'; +import { AuthKeyGuard } from '../../authentication/authKey.guard'; +import { TenantServiceInterface } from '../service/tenant.service.interface'; + +@Resolver('Tenant') +export class TenantResolver { + constructor( + @Inject(TenantServiceInterface) + private tenantService: TenantServiceInterface, + ) {} + + @UseGuards(AuthKeyGuard) + @Mutation() + async createTenant( + @Args('input') tenantInput: NewTenantInput, + ): Promise { + return this.tenantService.createTenant(tenantInput); + } +} diff --git a/src/tenant/service/tenant.service.interface.ts b/src/tenant/service/tenant.service.interface.ts new file mode 100644 index 0000000..85be7c0 --- /dev/null +++ b/src/tenant/service/tenant.service.interface.ts @@ -0,0 +1,12 @@ +import { NewTenantInput } from 'src/schema/graphql.schema'; +import Tenant from '../entity/tenant.entity'; + +export interface TenantServiceInterface { + getTenantByDomain(domain: string): Promise; + + createTenant(tenant: NewTenantInput): Promise; + + setTenantIdInContext(userDetails: any): Promise; +} + +export const TenantServiceInterface = Symbol('TenantServiceInterface'); diff --git a/src/tenant/service/tenant.service.ts b/src/tenant/service/tenant.service.ts new file mode 100644 index 0000000..ea05514 --- /dev/null +++ b/src/tenant/service/tenant.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; + +import Tenant from '../entity/tenant.entity'; +import { TenantNotFoundException } from '../exception/tenant.exception'; +import { TenantRepository } from '../repository/tenant.repository'; +import { NewTenantInput } from '../../schema/graphql.schema'; +import { ConfigService } from '@nestjs/config'; +import { ExecutionManager } from '../../util/execution.manager'; +import { TenantServiceInterface } from './tenant.service.interface'; + +@Injectable() +export default class TenantService implements TenantServiceInterface { + private readonly multiTenancyEnabled: boolean; + private readonly defaultTenantId: string; + constructor( + private tenantRepository: TenantRepository, + private configService: ConfigService, + ) { + this.multiTenancyEnabled = + this.configService.get('MULTI_TENANCY_ENABLED') === 'true'; + this.defaultTenantId = this.configService.get( + 'DEFAULT_TENANT_ID', + ) as string; + } + + async getTenantByDomain(domain: string): Promise { + const tenant = await this.tenantRepository.getTenantByDomain(domain); + if (!tenant) { + throw new TenantNotFoundException(domain); + } + return tenant; + } + + async createTenant(tenant: NewTenantInput): Promise { + return this.tenantRepository.save(tenant); + } + + async setTenantIdInContext(userDetails: any): Promise { + let tenantId: string; + if (this.multiTenancyEnabled) { + const domain = + userDetails.tenantDomain || + userDetails.email?.split('@')[1] || + userDetails.username?.split('@')[1]; + const tenant = userDetails.tenantId + ? { id: userDetails.tenantId } + : await this.getTenantByDomain(domain); + tenantId = tenant.id; + } else { + tenantId = this.defaultTenantId; + } + ExecutionManager.setTenantId(tenantId); + } +} diff --git a/src/tenant/tenant.module.ts b/src/tenant/tenant.module.ts new file mode 100644 index 0000000..27dd1d6 --- /dev/null +++ b/src/tenant/tenant.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import Tenant from '../tenant/entity/tenant.entity'; +import { TenantResolver } from '../tenant/resolver/tenant.resolver'; +import TenantService from '../tenant/service/tenant.service'; +import { TenantRepository } from '../tenant/repository/tenant.repository'; +import { TenantServiceInterface } from '../tenant/service/tenant.service.interface'; + +@Module({ + imports: [TypeOrmModule.forFeature([Tenant])], + providers: [ + ConfigService, + TenantResolver, + TenantRepository, + { + provide: TenantServiceInterface, + useClass: TenantService, + }, + ], + exports: [ + { + provide: TenantServiceInterface, + useClass: TenantService, + }, + ], +}) +export class TenantModule {} diff --git a/src/util/execution.manager.ts b/src/util/execution.manager.ts new file mode 100644 index 0000000..d92f6c7 --- /dev/null +++ b/src/util/execution.manager.ts @@ -0,0 +1,40 @@ +import { v4 as UUIDV4 } from 'uuid'; +import { AsyncLocalStorage } from 'async_hooks'; + +export class ExecutionManager { + private static asl: AsyncLocalStorage>; + + static init() { + ExecutionManager.asl = new AsyncLocalStorage(); + } + + static runWithContext(callback: (...args: any[]) => R): R { + return ExecutionManager.getExecutionContext().run( + new Map([ + ['id', UUIDV4()], + ['startTime', new Date().toISOString()], + ]), + () => callback.apply(this), + ); + } + + private static getExecutionContext() { + return ExecutionManager.asl; + } + + public static getFromContext(key: string) { + return ExecutionManager.getExecutionContext()?.getStore()?.get(key) as T; + } + + public static setInContext(key: string, value: any) { + ExecutionManager.getExecutionContext()?.getStore()?.set(key, value); + } + + static setTenantId(tenantId: string) { + ExecutionManager.setInContext('tenantId', tenantId); + } + + static getTenantId() { + return ExecutionManager.getFromContext('tenantId'); + } +} diff --git a/test/authentication/resolver/userauth.resolver.test.ts b/test/authentication/resolver/userauth.resolver.test.ts index cf71957..dd17806 100644 --- a/test/authentication/resolver/userauth.resolver.test.ts +++ b/test/authentication/resolver/userauth.resolver.test.ts @@ -35,6 +35,7 @@ const users: User[] = [ lastName: 'User', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -86,6 +87,7 @@ describe('Userauth Module', () => { const input: UserPasswordLoginInput = { username: 'user@test.com', password: 's3cr3t1234567890', + tenantDomain: 'domain.com', }; const user = { id: users[0].id, @@ -114,7 +116,7 @@ describe('Userauth Module', () => { .post(gql) .send({ query: - 'mutation { passwordLogin(input: { username: "user@test.com" password: "s3cr3t1234567890" }) { accessToken, refreshToken, user{ id, email, phone, firstName, lastName, status } }}', + 'mutation { passwordLogin(input: { username: "user@test.com" password: "s3cr3t1234567890" tenantDomain: "domain.com" }) { accessToken, refreshToken, user{ id, email, phone, firstName, lastName, status}}}', }) .expect(200) .expect((res) => { @@ -349,7 +351,7 @@ describe('Userauth Module', () => { return request(app.getHttpServer()) .post(gql) .send({ - query: `mutation { refresh(input: { refreshToken: "${token.refreshToken}"}) { accessToken refreshToken user { id, email, phone, firstName, lastName, status} }}`, + query: `mutation { refresh(input: { refreshToken: "${token.refreshToken}"}) { accessToken refreshToken user { id, email, phone, firstName, lastName, status}}}`, }) .expect(200) .expect((res) => { diff --git a/test/authentication/service/otpauth.service.test.ts b/test/authentication/service/otpauth.service.test.ts index 97f66c2..d1af984 100644 --- a/test/authentication/service/otpauth.service.test.ts +++ b/test/authentication/service/otpauth.service.test.ts @@ -17,6 +17,7 @@ import { UserOTPLoginInput, UserOTPSignupInput, } from '../../../src/schema/graphql.schema'; +import { TenantServiceInterface } from '../../../src/tenant/service/tenant.service.interface'; let users: User[] = [ { @@ -28,6 +29,7 @@ let users: User[] = [ lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -35,6 +37,7 @@ describe('test OTPAuthService', () => { let otpAuthService: OTPAuthService; let authenticationHelper: AuthenticationHelper; const userService = Substitute.for(); + const tenantService = Substitute.for(); const configService = Substitute.for(); const otpService = Substitute.for(); const tokenService = Substitute.for(); @@ -47,6 +50,7 @@ describe('test OTPAuthService', () => { controllers: [], providers: [ { provide: UserServiceInterface, useValue: userService }, + { provide: TenantServiceInterface, useValue: tenantService }, { provide: ConfigService, useValue: configService }, { provide: TokenService, useValue: tokenService }, { provide: 'OTPVerifiable', useValue: otpService }, @@ -72,6 +76,7 @@ describe('test OTPAuthService', () => { refreshToken: token.refreshToken, origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; }); @@ -88,6 +93,7 @@ describe('test OTPAuthService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -126,6 +132,7 @@ describe('test OTPAuthService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const input: UserOTPLoginInput = { @@ -152,6 +159,7 @@ describe('test OTPAuthService', () => { lastName: users[0].lastName, origin: 'simple', status: Status.ACTIVE, + tenantId: users[0].tenantId, }, ]; const userSignup: UserOTPSignupInput = { @@ -179,6 +187,7 @@ describe('test OTPAuthService', () => { lastName: resp.lastName, origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }; const expectedUser = users[0]; @@ -195,6 +204,7 @@ describe('test OTPAuthService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const userSignup: UserOTPSignupInput = { @@ -228,7 +238,7 @@ describe('test OTPAuthService', () => { .getActiveUserByPhoneNumber('91234567980') .returns(Promise.resolve(users[0])); - const resp = await otpAuthService.sendOTP('91234567980'); + const resp = await otpAuthService.sendOTP({ phone: '91234567980' }); expect(resp).toHaveReturned; }); }); diff --git a/test/authentication/service/passwordauth.service.test.ts b/test/authentication/service/passwordauth.service.test.ts index 89ca024..dd6f0bf 100644 --- a/test/authentication/service/passwordauth.service.test.ts +++ b/test/authentication/service/passwordauth.service.test.ts @@ -8,6 +8,8 @@ import { TokenService } from '../../../src/authentication/service/token.service' import User from '../../../src/authorization/entity/user.entity'; import { UserServiceInterface } from '../../../src/authorization/service/user.service.interface'; import { Status } from '../../../src/schema/graphql.schema'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; +import { TenantServiceInterface } from '../../../src/tenant/service/tenant.service.interface'; let users: User[] = [ { @@ -19,6 +21,7 @@ let users: User[] = [ lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -26,6 +29,7 @@ describe('test PasswordAuthService', () => { let passwordAuthService: PasswordAuthService; let authenticationHelper: AuthenticationHelper; const userService = Substitute.for(); + const tenantService = Substitute.for(); const configService = Substitute.for(); const tokenService = Substitute.for(); configService.get('ENV').returns('local'); @@ -44,12 +48,17 @@ describe('test PasswordAuthService', () => { controllers: [], providers: [ { provide: UserServiceInterface, useValue: userService }, + { provide: TenantServiceInterface, useValue: tenantService }, { provide: ConfigService, useValue: configService }, { provide: TokenService, useValue: tokenService }, { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, PasswordAuthService, AuthenticationHelper, ], @@ -74,6 +83,7 @@ describe('test PasswordAuthService', () => { refreshToken: token.refreshToken, origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; }); diff --git a/test/authentication/service/token.service.test.ts b/test/authentication/service/token.service.test.ts index a9590fa..28bbf9a 100644 --- a/test/authentication/service/token.service.test.ts +++ b/test/authentication/service/token.service.test.ts @@ -47,6 +47,7 @@ describe('test TokenService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const token = authenticationHelper.generateTokenForUser(users[0]); @@ -71,6 +72,7 @@ describe('test TokenService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const refreshInviteToken = authenticationHelper.generateInvitationToken( @@ -103,6 +105,7 @@ describe('test TokenService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; userService @@ -125,6 +128,7 @@ describe('test TokenService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; userService @@ -144,6 +148,7 @@ describe('test TokenService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; users[0].inviteToken = ''; diff --git a/test/authorization/repository/entity.repository.test.ts b/test/authorization/repository/entity.repository.test.ts index 27dab75..fdd3d18 100644 --- a/test/authorization/repository/entity.repository.test.ts +++ b/test/authorization/repository/entity.repository.test.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { Entity, UpdateEntityInput } from 'src/schema/graphql.schema'; import { DataSource, UpdateResult } from 'typeorm'; import { EntityModelRepository } from '../../../src/authorization/repository/entity.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_ENTITY_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; @@ -33,6 +34,10 @@ describe('test Entity model repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/group.repository.test.ts b/test/authorization/repository/group.repository.test.ts index f584df9..6eac46e 100644 --- a/test/authorization/repository/group.repository.test.ts +++ b/test/authorization/repository/group.repository.test.ts @@ -5,12 +5,14 @@ import * as typeorm from 'typeorm'; import { DataSource, UpdateResult } from 'typeorm'; import UserGroup from '../../../src/authorization/entity/userGroup.entity'; import { GroupRepository } from '../../../src/authorization/repository/group.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_GROUP_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; const group: Group = { id: VALID_GROUP_ID, name: 'Test Group 1', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }; const updateResult: UpdateResult = { @@ -38,6 +40,10 @@ describe('test Group repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/groupPermission.repository.test.ts b/test/authorization/repository/groupPermission.repository.test.ts index 24430e2..e684e85 100644 --- a/test/authorization/repository/groupPermission.repository.test.ts +++ b/test/authorization/repository/groupPermission.repository.test.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import GroupPermission from 'src/authorization/entity/groupPermission.entity'; import { DataSource } from 'typeorm'; import { GroupPermissionRepository } from '../../../src/authorization/repository/groupPermission.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_PERMISSION_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; const VALID_GROUP_ID = '3282163d-fd5a-4026-b912-1a9cc5eefc98'; @@ -10,6 +11,7 @@ const groupPermissions: GroupPermission[] = [ { groupId: VALID_GROUP_ID, permissionId: VALID_PERMISSION_ID, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -29,6 +31,10 @@ describe('test GroupPermission repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/groupRole.repository.test.ts b/test/authorization/repository/groupRole.repository.test.ts index 99ccd12..204ea5c 100644 --- a/test/authorization/repository/groupRole.repository.test.ts +++ b/test/authorization/repository/groupRole.repository.test.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import { DataSource } from 'typeorm'; import Group from '../../../src/authorization/entity/group.entity'; import { GroupRoleRepository } from '../../../src/authorization/repository/groupRole.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_ROLE_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; @@ -21,6 +22,10 @@ describe('test GroupRole repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/role.repository.test.ts b/test/authorization/repository/role.repository.test.ts index 48949fa..c4abed5 100644 --- a/test/authorization/repository/role.repository.test.ts +++ b/test/authorization/repository/role.repository.test.ts @@ -4,12 +4,14 @@ import { UpdateRoleInput } from 'src/schema/graphql.schema'; import { DataSource, In, UpdateResult } from 'typeorm'; import GroupRole from '../../../src/authorization/entity/groupRole.entity'; import { RoleRepository } from '../../../src/authorization/repository/role.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_ROLE_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; const role: Role = { id: VALID_ROLE_ID, name: 'Test Role 1', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }; const updateResult: UpdateResult = { @@ -37,6 +39,10 @@ describe('test Role repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/rolePermission.repository.test.ts b/test/authorization/repository/rolePermission.repository.test.ts index 70ab8ae..6088673 100644 --- a/test/authorization/repository/rolePermission.repository.test.ts +++ b/test/authorization/repository/rolePermission.repository.test.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import RolePermission from 'src/authorization/entity/rolePermission.entity'; import { DataSource } from 'typeorm'; import { RolePermissionRepository } from '../../../src/authorization/repository/rolePermission.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; describe('test RolePermission repository', () => { let rolePermissionRepository: RolePermissionRepository; @@ -15,10 +16,12 @@ describe('test RolePermission repository', () => { { permissionId: '2b33268a-7ff5-4cac-a87a-6bfc4430d34c', roleId: VALID_ROLE_ID, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, { permissionId: '09f7f119-c14b-4c37-ac1f-aae57d7bdbe5', roleId: VALID_ROLE_ID, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -30,6 +33,10 @@ describe('test RolePermission repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/user.repository.test.ts b/test/authorization/repository/user.repository.test.ts index f0072e2..6b967b7 100644 --- a/test/authorization/repository/user.repository.test.ts +++ b/test/authorization/repository/user.repository.test.ts @@ -4,6 +4,7 @@ import { DataSource, In, UpdateResult } from 'typeorm'; import UserGroup from '../../../src/authorization/entity/userGroup.entity'; import { UserRepository } from '../../../src/authorization/repository/user.repository'; import { Status, UpdateUserInput } from '../../../src/schema/graphql.schema'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_USER_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; const VALID_EMAIL = 'test@valid.com'; @@ -17,6 +18,7 @@ const users: User[] = [ origin: 'simple', status: Status.ACTIVE, email: VALID_EMAIL, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -48,6 +50,10 @@ describe('test User repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/userGroup.repository.test.ts b/test/authorization/repository/userGroup.repository.test.ts index 815132b..6a1c4bc 100644 --- a/test/authorization/repository/userGroup.repository.test.ts +++ b/test/authorization/repository/userGroup.repository.test.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import UserGroup from 'src/authorization/entity/userGroup.entity'; import { DataSource } from 'typeorm'; import { UserGroupRepository } from '../../../src/authorization/repository/userGroup.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_USER_ID = 'ccecef4f-58d3-477b-87ee-847ee22efe4d'; const VALID_GROUP_ID = '3282163d-fd5a-4026-b912-1a9cc5eefc98'; @@ -10,6 +11,7 @@ const userGroups: UserGroup[] = [ { userId: VALID_USER_ID, groupId: VALID_GROUP_ID, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -28,6 +30,10 @@ describe('test UserGroup repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/repository/userPermission.repository.test.ts b/test/authorization/repository/userPermission.repository.test.ts index b9983d0..1f09c8e 100644 --- a/test/authorization/repository/userPermission.repository.test.ts +++ b/test/authorization/repository/userPermission.repository.test.ts @@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing'; import UserPermission from 'src/authorization/entity/userPermission.entity'; import { DataSource } from 'typeorm'; import { UserPermissionRepository } from '../../../src/authorization/repository/userPermission.repository'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_PERMISSION_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; @@ -9,6 +10,7 @@ const userPermissions: UserPermission[] = [ { permissionId: VALID_PERMISSION_ID, userId: 'ccecef4f-58d3-477b-87ee-847ee22efe4d', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -28,6 +30,10 @@ describe('test UserPermission repository', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/resolver/entity.resolver.test.ts b/test/authorization/resolver/entity.resolver.test.ts index ade13d8..1f04675 100644 --- a/test/authorization/resolver/entity.resolver.test.ts +++ b/test/authorization/resolver/entity.resolver.test.ts @@ -17,6 +17,7 @@ import { UpdateEntityPermissionInput, } from '../../../src/schema/graphql.schema'; import { mockedConfigService } from '../../utils/mocks/config.service'; +import EntityModel from '../../../src/authorization/entity/entity.entity'; const gql = '/graphql'; @@ -30,6 +31,7 @@ const users: User[] = [ lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -91,14 +93,18 @@ describe('Entity Module', () => { describe(gql, () => { describe('entities', () => { it('should get the entities', () => { - entityService.getAllEntities().returns(Promise.resolve(entities)); + entityService + .getAllEntities() + .returns(Promise.resolve(entities as EntityModel[])); entityService .getEntityPermissions('2b33268a-7ff5-4cac-a87a-6bfc4430d34c') .returns(Promise.resolve(permissions)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) - .send({ query: '{getEntities {id name permissions { id name} }}' }) + .send({ + query: '{getEntities {id name permissions { id name}}}', + }) .expect(200) .expect((res) => { expect(res.body.data.getEntities).toEqual(allEntities); @@ -108,13 +114,13 @@ describe('Entity Module', () => { it('should get single entity', () => { entityService .getEntityById('ae032b1b-cc3c-4e44-9197-276ca877a7f8') - .returns(Promise.resolve(entities[0])); + .returns(Promise.resolve(entities[0] as EntityModel)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - '{getEntity(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name }}', + '{getEntity(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name}}', }) .expect(200) .expect((res) => { @@ -129,13 +135,12 @@ describe('Entity Module', () => { const obj = Object.create(null); entityService .createEntity(Object.assign(obj, input)) - .returns(Promise.resolve(entities[0])); + .returns(Promise.resolve(entities[0] as EntityModel)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ - query: - 'mutation { createEntity(input: {name: "Test1"}) {id name }}', + query: 'mutation { createEntity(input: {name: "Test1"}) {id name}}', }) .expect(200) .expect((res) => { @@ -153,13 +158,13 @@ describe('Entity Module', () => { 'ae032b1b-cc3c-4e44-9197-276ca877a7f8', Object.assign(obj, input), ) - .returns(Promise.resolve(entities[0])); + .returns(Promise.resolve(entities[0] as EntityModel)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - 'mutation { updateEntity(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8", input: {name: "Test1"}) {id name }}', + 'mutation { updateEntity(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8", input: {name: "Test1"}) {id name}}', }) .expect(200) .expect((res) => { @@ -170,13 +175,13 @@ describe('Entity Module', () => { it('should delete a entity', () => { entityService .deleteEntity('ae032b1b-cc3c-4e44-9197-276ca877a7f8') - .returns(Promise.resolve(entities[0])); + .returns(Promise.resolve(entities[0] as EntityModel)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - 'mutation { deleteEntity(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name }}', + 'mutation { deleteEntity(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name}}', }) .expect(200) .expect((res) => { diff --git a/test/authorization/resolver/group.resolver.test.ts b/test/authorization/resolver/group.resolver.test.ts index 9329ed5..116f03c 100644 --- a/test/authorization/resolver/group.resolver.test.ts +++ b/test/authorization/resolver/group.resolver.test.ts @@ -32,10 +32,11 @@ const users: User[] = [ lastName: 'Test2', origin: 'simple', status: GqlSchema.Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; -const groups: Group[] = [ +const groups = [ { id: '2b33268a-7ff5-4cac-a87a-6bfc4430d34c', name: 'Customers', @@ -91,6 +92,7 @@ describe('Group Module', () => { { id: 'f56bc83b-b163-4fa0-a685-c0fa0926614c', name: 'Test Group Role', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const allPermissions: Permission[] = [ @@ -144,7 +146,9 @@ describe('Group Module', () => { groupService .getAllGroupPermissions(groups[0].id) .returns(Promise.resolve(allPermissions)); - groupService.getAllGroups().returns(Promise.resolve([groups, 1])); + groupService + .getAllGroups() + .returns(Promise.resolve([groups as Group[], 1])); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) @@ -163,6 +167,7 @@ describe('Group Module', () => { const group: Group = { id: '4a3c33a9-983e-44c0-ad22-bdc5a84c2c75', name: 'Customers', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }; const permissions: Permission[] = [ { @@ -174,6 +179,7 @@ describe('Group Module', () => { { id: '9942109f-026b-4f2f-a26f-5ceb5f911ba6', name: 'Test Group Role', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const allPermissions: Permission[] = [ @@ -244,12 +250,12 @@ describe('Group Module', () => { groupService .createGroup(Object.assign(obj, input)) - .returns(Promise.resolve(groups[0])); + .returns(Promise.resolve(groups[0] as Group)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ - query: 'mutation { createGroup(input: {name: "Test1"}) {id name }}', + query: 'mutation { createGroup(input: {name: "Test1"}) {id name}}', }) .expect(200) .expect((res) => { @@ -269,13 +275,13 @@ describe('Group Module', () => { 'ae032b1b-cc3c-4e44-9197-276ca877a7f8', Object.assign(obj, input), ) - .returns(Promise.resolve(groups[0])); + .returns(Promise.resolve(groups[0] as Group)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - 'mutation { updateGroup(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8", input: {name: "Test1"}) {id name }}', + 'mutation { updateGroup(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8", input: {name: "Test1"}) {id name}}', }) .expect(200) .expect((res) => { @@ -288,13 +294,13 @@ describe('Group Module', () => { groupService .deleteGroup('ae032b1b-cc3c-4e44-9197-276ca877a7f8') - .returns(Promise.resolve(groups[0])); + .returns(Promise.resolve(groups[0] as Group)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - 'mutation { deleteGroup(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name }}', + 'mutation { deleteGroup(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name}}', }) .expect(200) .expect((res) => { @@ -341,15 +347,17 @@ describe('Group Module', () => { lastName: 'Test2', origin: 'simple', status: GqlSchema.Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const groupInPayload: Group[] = [ { id: '836cccce-8ff6-40e9-9fc7-2dd5cba3f514', name: 'HR', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; - const roles: Role[] = [ + const roles = [ { id: 'fcd858c6-26c5-462b-8c53-4b544830dca8', name: 'Customers', @@ -369,7 +377,7 @@ describe('Group Module', () => { .returns(Promise.resolve(groupInPayload[0])); groupService .getGroupRoles('836cccce-8ff6-40e9-9fc7-2dd5cba3f514') - .returns(Promise.resolve(roles)); + .returns(Promise.resolve(roles as Role[])); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) diff --git a/test/authorization/resolver/permission.resolver.test.ts b/test/authorization/resolver/permission.resolver.test.ts index 0780f43..93d2a0e 100644 --- a/test/authorization/resolver/permission.resolver.test.ts +++ b/test/authorization/resolver/permission.resolver.test.ts @@ -28,6 +28,7 @@ const users: User[] = [ lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const permissions: Permission[] = [ diff --git a/test/authorization/resolver/role.resolver.test.ts b/test/authorization/resolver/role.resolver.test.ts index f90e884..27488bd 100644 --- a/test/authorization/resolver/role.resolver.test.ts +++ b/test/authorization/resolver/role.resolver.test.ts @@ -31,10 +31,11 @@ const users: User[] = [ lastName: 'Test2', origin: 'simple', status: GqlSchema.Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; -const roles: Role[] = [ +const roles = [ { id: '2b33268a-7ff5-4cac-a87a-6bfc4430d34c', name: 'Customers', @@ -97,7 +98,9 @@ describe('Role Module', () => { }; const token = authenticationHelper.generateAccessToken(users[0]); - roleService.getAllRoles().returns(Promise.resolve([roles, 1])); + roleService + .getAllRoles() + .returns(Promise.resolve([roles as Role[], 1])); roleService .getRolePermissions(roles[0].id) .returns(Promise.resolve(permissions)); @@ -106,7 +109,7 @@ describe('Role Module', () => { .set('Authorization', `Bearer ${token}`) .send({ query: - '{getRoles { totalCount results { id name permissions { id name }}}}', + '{getRoles { totalCount results { id name permissions { id name}}}}', }) .expect(200) .expect((res) => { @@ -119,13 +122,13 @@ describe('Role Module', () => { roleService .getRoleById('ae032b1b-cc3c-4e44-9197-276ca877a7f8') - .returns(Promise.resolve(roles[0])); + .returns(Promise.resolve(roles[0] as Role)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - '{getRole(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name }}', + '{getRole(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name}}', }) .expect(200) .expect((res) => { @@ -142,12 +145,12 @@ describe('Role Module', () => { roleService .createRole(Object.assign(obj, input)) - .returns(Promise.resolve(roles[0])); + .returns(Promise.resolve(roles[0] as Role)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ - query: 'mutation { createRole(input: {name: "Test1"}) {id name }}', + query: 'mutation { createRole(input: {name: "Test1"}) {id name}}', }) .expect(200) .expect((res) => { @@ -167,13 +170,13 @@ describe('Role Module', () => { 'ae032b1b-cc3c-4e44-9197-276ca877a7f8', Object.assign(obj, input), ) - .returns(Promise.resolve(roles[0])); + .returns(Promise.resolve(roles[0] as Role)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - 'mutation { updateRole(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8", input: {name: "Test1"}) {id name }}', + 'mutation { updateRole(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8", input: {name: "Test1"}) {id name}}', }) .expect(200) .expect((res) => { @@ -186,13 +189,13 @@ describe('Role Module', () => { roleService .deleteRole('ae032b1b-cc3c-4e44-9197-276ca877a7f8') - .returns(Promise.resolve(roles[0])); + .returns(Promise.resolve(roles[0] as Role)); return request(app.getHttpServer()) .post(gql) .set('Authorization', `Bearer ${token}`) .send({ query: - 'mutation { deleteRole(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name }}', + 'mutation { deleteRole(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f8") {id name}}', }) .expect(200) .expect((res) => { @@ -238,11 +241,13 @@ describe('Role Module', () => { lastName: 'Test2', origin: 'simple', status: GqlSchema.Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const roleInPayload: Role = { id: 'cf525c1d-e64d-462c-8cdc-e55eb9234b9e', name: 'Role1', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }; const permissions: Permission[] = [ { @@ -291,6 +296,7 @@ describe('Role Module', () => { lastName: 'Test2', origin: 'simple', status: GqlSchema.Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const permissions: Permission[] = [ diff --git a/test/authorization/resolver/user.resolver.test.ts b/test/authorization/resolver/user.resolver.test.ts index be670cd..4dbafe5 100644 --- a/test/authorization/resolver/user.resolver.test.ts +++ b/test/authorization/resolver/user.resolver.test.ts @@ -28,6 +28,7 @@ const users: User[] = [ lastName: 'User', origin: 'simple', status: GqlSchema.Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -57,6 +58,7 @@ const groups: Group[] = [ { id: '2b33268a-7ff5-4cac-a87a-6bfc4430d34c', name: 'Customers', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -138,6 +140,7 @@ describe('User Module', () => { lastName: users[0].lastName, origin: 'simple', status: GqlSchema.Status.ACTIVE, + tenantId: users[0].tenantId, }; const finalResponse: GqlSchema.User = { @@ -165,7 +168,7 @@ describe('User Module', () => { .set('Authorization', `Bearer ${token}`) .send({ query: - '{getUser(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f9") { id email phone firstName lastName status permissions { id name } }}', + '{getUser(id: "ae032b1b-cc3c-4e44-9197-276ca877a7f9") { id email phone firstName lastName status permissions { id name }}}', }) .expect(200) .expect((res) => { diff --git a/test/authorization/service/group.service.test.ts b/test/authorization/service/group.service.test.ts index 280add0..310ec58 100644 --- a/test/authorization/service/group.service.test.ts +++ b/test/authorization/service/group.service.test.ts @@ -26,12 +26,14 @@ import { Status, UpdateGroupInput, } from '../../../src/schema/graphql.schema'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_GROUP_ID = '3282163d-fd5a-4026-b912-1a9cc5eefc98'; const groups: Group[] = [ { id: VALID_GROUP_ID, name: 'Test1', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -53,6 +55,7 @@ const users: User[] = [ lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -160,6 +163,10 @@ describe('test Group Service', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, // { // provide: DataSource, // useFactory: dataSourceMockFactory, diff --git a/test/authorization/service/permission.service.test.ts b/test/authorization/service/permission.service.test.ts index 9bcbb8e..9993988 100644 --- a/test/authorization/service/permission.service.test.ts +++ b/test/authorization/service/permission.service.test.ts @@ -15,6 +15,7 @@ import { NewPermissionInput, UpdatePermissionInput, } from '../../../src/schema/graphql.schema'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_PERMISSION_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; const INVALID_PERMISSION_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f9'; @@ -59,6 +60,10 @@ describe('test Permission service', () => { provide: PermissionCacheServiceInterface, useValue: mockPermissionCacheService, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); diff --git a/test/authorization/service/role.service.test.ts b/test/authorization/service/role.service.test.ts index 2c63953..45a0b51 100644 --- a/test/authorization/service/role.service.test.ts +++ b/test/authorization/service/role.service.test.ts @@ -24,6 +24,7 @@ import { NewRoleInput, UpdateRoleInput, } from '../../../src/schema/graphql.schema'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const VALID_ROLE_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f8'; const INVALID_ROLE_ID = 'ae032b1b-cc3c-4e44-9197-276ca877a7f9'; @@ -31,6 +32,7 @@ const roles: Role[] = [ { id: VALID_ROLE_ID, name: 'Test1', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; const permissions: Permission[] = [ @@ -95,6 +97,10 @@ describe('test Role Service', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); @@ -199,6 +205,7 @@ describe('test Role Service', () => { expect(result).toEqual({ id: VALID_ROLE_ID, name: 'Test1', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }); }); }); diff --git a/test/authorization/service/user.service.test.ts b/test/authorization/service/user.service.test.ts index 5899a49..eda2ba4 100644 --- a/test/authorization/service/user.service.test.ts +++ b/test/authorization/service/user.service.test.ts @@ -23,6 +23,7 @@ import { UserServiceInterface } from '../../../src/authorization/service/user.se import { UserCacheServiceInterface } from '../../../src/authorization/service/usercache.service.interface'; import { RedisCacheService } from '../../../src/cache/redis-cache/redis-cache.service'; import { Status } from '../../../src/schema/graphql.schema'; +import { TENANT_CONNECTION } from '../../../src/database/database.constants'; const users: User[] = [ { @@ -34,6 +35,7 @@ const users: User[] = [ lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -49,6 +51,7 @@ const groups: Group[] = [ { id: '39d338b9-02bd-4971-a24e-b39a3f475580', name: 'Customers', + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }, ]; @@ -140,6 +143,10 @@ describe('test UserService', () => { provide: DataSource, useValue: mockDataSource, }, + { + provide: TENANT_CONNECTION, + useValue: mockDataSource, + }, ], }).compile(); userService = moduleRef.get(UserService); @@ -213,6 +220,7 @@ describe('test UserService', () => { lastName: 'Test2', origin: 'simple', status: Status.ACTIVE, + tenantId: '1ef2a357-d4b7-4a30-88ca-d1cc627f2994', }; saveMock.mockResolvedValue(users[0]);