Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
36c2e08
FEAT: Multi-tenancy support
bharathkeyvalue Dec 11, 2024
66d5966
TEST: multi-tenent test fixes
bharathkeyvalue Dec 11, 2024
1e4d149
FEAT: save tenantId in execution context
bharathkeyvalue Dec 11, 2024
2900db2
FEAT: add postgres rls for tenant isolation
sru-thy Dec 11, 2024
67f6134
FIX: make execution context binder injectable
sru-thy Dec 11, 2024
65513d8
FEAT: use new database connection per tenant
sru-thy Dec 12, 2024
7572a60
FEAT: add tenant creation api
bharathkeyvalue Dec 12, 2024
5e0b2a6
REFACTOR: remove tenantId in response
bharathkeyvalue Dec 12, 2024
9d10fea
FEAT: modified usages of db queries to use tenant specific connection
sru-thy Dec 12, 2024
001b8bb
FIX: add configurable max connection limit per tenant if needed
sru-thy Dec 13, 2024
ea0526f
REFACTOR: rename executionId.middleware.ts to executionContext.middle…
sru-thy Dec 13, 2024
93da280
FEAT: use sepearate db users for migrations and tenant operations
sru-thy Dec 13, 2024
29168b2
FIX: use dynamic connection for user permission updation
sru-thy Dec 13, 2024
f9c3033
FIX: use admin user for db connection setup
sru-thy Dec 13, 2024
f0c0d75
FEAT: add env validations for new postgres user variables
sru-thy Dec 13, 2024
c0ec4c5
DOCS: update README with PostgresSQL admin and tenant user setup
sru-thy Dec 13, 2024
d0f032c
FEAT: add tenant module
bharathkeyvalue Dec 13, 2024
c985e37
REFACTOR: use NestJS dependency injection in the request scope to obt…
sru-thy Dec 17, 2024
12e0a8e
REFACTOR: add util function for extracting token
bharathkeyvalue Dec 17, 2024
1e7837b
BLD: Add db init script for creation of tenant user and db
sru-thy Dec 18, 2024
cf1df66
DOC: Added description for multi tenancy support
sru-thy Dec 18, 2024
6fef4da
FEAT: Handle login for multi-tenancy
bharathkeyvalue Dec 18, 2024
28d64e2
DOC: Add description for env variables used for handling multi-tenanc…
bharathkeyvalue Dec 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

 
Expand Down
10 changes: 8 additions & 2 deletions env.sample
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Superuser for migrations
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
# Minimal user with restricted access
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a snippet in the readme how multi-tenancy is handled? And also the ways to create a tenant Postgres user.

Let's add a DB Init script in the docker-compose.yml file as well

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated in commit here

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
Expand Down Expand Up @@ -32,3 +37,4 @@ MIN_RECAPTCHA_SCORE=.5
RECAPTCHA_VERIFY_URL=https://www.google.com/recaptcha/api/siteverify
DEFAULT_ADMIN_PASSWORD=adminpassword
INVITATION_TOKEN_EXPTIME = 7d
AUTH_KEY=
34 changes: 26 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
15 changes: 11 additions & 4 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -7,15 +7,18 @@ 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: [
ConfigModule.forRoot({
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),
Expand All @@ -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('*');
}
}
20 changes: 20 additions & 0 deletions src/authentication/authKey.guard.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 1 addition & 1 deletion src/authentication/authentication.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
1 change: 1 addition & 0 deletions src/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,6 @@ const providers: Provider[] = [
],
providers,
controllers: [GoogleAuthController],
exports: [AuthenticationHelper],
})
export class UserAuthModule {}
54 changes: 29 additions & 25 deletions src/authentication/service/password.auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '../exception/userauth.exception';
import { Authenticatable } from '../interfaces/authenticatable';
import { TokenService } from './token.service';
import { getConnection } from '../../util/database.connection';

@Injectable()
export default class PasswordAuthService implements Authenticatable {
Expand Down Expand Up @@ -86,31 +87,34 @@ export default class PasswordAuthService implements Authenticatable {
userFromInput.lastName = userDetails.lastName;
userFromInput.status = Status.INVITED;
let invitationToken: { token: any; tokenExpiryTime?: any };
const transaction = await this.dataSource.manager.transaction(async () => {
const savedUser = await this.userService.createUser(userFromInput);
invitationToken = this.authenticationHelper.generateInvitationToken(
{ id: savedUser.id },
this.configService.get('INVITATION_TOKEN_EXPTIME'),
);
await this.userService.updateField(
savedUser.id,
'inviteToken',
invitationToken.token,
);
const user = await this.userService.getUserById(savedUser.id);
const userResponse = {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
inviteToken: user?.inviteToken,
status: user.status,
};
return {
inviteToken: invitationToken.token,
tokenExpiryTime: invitationToken.tokenExpiryTime,
user: userResponse,
};
});
const transaction = await (await getConnection()).manager.transaction(
async () => {
const savedUser = await this.userService.createUser(userFromInput);
invitationToken = this.authenticationHelper.generateInvitationToken(
{ id: savedUser.id },
this.configService.get('INVITATION_TOKEN_EXPTIME'),
);
await this.userService.updateField(
savedUser.id,
'inviteToken',
invitationToken.token,
);
const user = await this.userService.getUserById(savedUser.id);
const userResponse = {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
inviteToken: user?.inviteToken,
status: user.status,
tenantId: user.tenantId,
};
return {
inviteToken: invitationToken.token,
tokenExpiryTime: invitationToken.tokenExpiryTime,
user: userResponse,
};
},
);
return transaction;
}

Expand Down
2 changes: 2 additions & 0 deletions src/authorization/authorization.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ 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';

@Module({
imports: [
Expand All @@ -67,6 +68,7 @@ import { UserCacheServiceInterface } from './service/usercache.service.interface
GroupRole,
RolePermission,
]),
TenantModule,
RedisCacheModule,
],
providers: [
Expand Down
12 changes: 12 additions & 0 deletions src/authorization/entity/abstract.tenant.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 2 additions & 2 deletions src/authorization/entity/entity.entity.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/authorization/entity/entityPermission.entity.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/authorization/entity/group.entity.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/authorization/entity/groupPermission.entity.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/authorization/entity/groupRole.entity.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/authorization/entity/role.entity.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Loading