Skip to content

NestJS - an module to enable multitenancy support with deep integration into the system as whole

License

Notifications You must be signed in to change notification settings

AlexanderC/nestjs-mtenant

Repository files navigation

Nest Logo

A module to enable multitenancy support with deep integration into the system as whole.

NPM Version Package License NPM Downloads

Rationale

Warning: This module is based on async_hooks, which is still an experimental feature. Use it on your own risk!

Multitenancy is widely used acros the web as software deployment options called whitelabels. Data in between tenants are separated, however nowadays there is business by sharing data inbetween the peer businesses; as an example might serve a E-commerce platform that shares their clients with twin/friendly shop, or there's some unified backoffice interface... Thus a good idea would be having the data under the same database (think namespace) instead of having to separate into different databases, in order to be able to query it efficiently and avoid duplication or synchronization issues.

Installation

npm install --save nestjs-mtenant sequelize-typescript
#or
yarn add nestjs-mtenant sequelize-typescript

Important: sequelize-typescript is required if you are using sequelize policy storage model.

Configuration

Configure your models:

// models/user.model.ts
import { MTEntity } from 'nestjs-mtenant';

// @MTEntity({ tenantField?: 'tenant', idField?: 'id' })
@MTEntity() 
@Table({})
export default class User extends Model<User> {
  id: string; // idField
  tenant: string; // tenantField
  username: string;
}

// models/book.model.ts
import { MTEntity } from 'nestjs-mtenant';

// @MTEntity({ tenantField?: 'tenant', idField?: 'id' })
@MTEntity() 
@Table({})
export default class Book extends Model<Book> {
  id: string; // idField
  tenant: string; // tenantField
  title: string;
  content: string;
}

Tenant Entity is enhanced with the following properties:

export interface TenantEntity {
  getTenant(): string; // e.g. "root"
  getTenantId(): string; // e.g. "root/33"
}

(OPTIONAL) Configure tenant storage using sequelize adapter:

// models/tenants-storage.model.ts
import { TenantsStorageSequelizeModel } from 'nestjs-mtenant';

export default class TenantsStorage extends TenantsStorageSequelizeModel<TenantsStorage> {  }

And finaly include the module and the service (assume using Nestjs Configuration):

// src/app.module.ts
import { MTModule, MTModuleOptions, SEQUELIZE_STORAGE, IOREDIS_CACHE } from 'nestjs-mtenant';
import { TenantSettingsDto } from './tenancy/tenant-settings.dto';
import TenantsStorage from '../models/tenants-storage.model';
import User from '../models/user.model';
import Book from '../models/book.model';

@Module({
  imports: [
    MTModule.forRootAsync({
      imports: [ConfigModule, RedisModule],
      inject: [ConfigService, RedisService],
      useFactory: async (configService: ConfigService, redisService: RedisService) => {
        return {
          for: [User, Book],
          // The below options are optional
          storage: SEQUELIZE_STORAGE,
          storageSettingsDto: TenantSettingsDto,
          storageRepository: TenantsStorage,
          cache: IOREDIS_CACHE,
          cacheClient: <IORedis.Redis>await redisService.getClient(), 
          cacheOptions: { expire: 600 }, // tenant cache expires in 10 minutes (default 1 hour)
        };
      },
    }),
  ],
},

Configuration reference:

export interface MTModuleOptions {
 for: Array<TenantEntity | Function>, // Entities to handle, e.g. [BookModel, UserModel]
 transport?: TenantTransport; // Tenant transport: http
 headerName?: string; // Header name to extract tenant from (if transport=http specified)
 queryParameterName?: string; // Query parameter name to extract tenant from (if transport=http specified)
 defaultTenant?: string; // Tenant to assign by default
 allowTenant?: (context: TenantContext, tenant: string) => boolean; // Allow certain requested tenant, augmented by tenant storage if setup
 allowMissingTenant?: boolean; // Get both IS NULL and tenant scopes on querying
 storage?: string | Storage; // dynamic tenant storage (e.g. sequelize)
 storageSettingsDto?: any, // Tenant settings interface
 storageRepository?: any; // if database storage specified
 cache?: string | Cache; // if storage specified! dynamic tenant storage cache (e.g. ioredis)
 cacheClient?: any; // if cache adapter specified
 cacheOptions?: CachedStorageOptions; // if cache adapter specified
}

Important: if storage is setup- allowTenant is augmented by storage manager. Which would mean that the manager checks if tenant exists in the storage itself and pass it to next to allowTenant guard (otherwise returning false, allowTenant not being called at all; @ref MTService.isTenantAllowed()).

Usage

There's literally nothing to configure, except a decorator to enrich Swagger docs by adding description of tenancy transport (e.g. through an @ApiHeader() and an @ApiQuery()) in your controllers that support multi-tenancy:

import { MTApi } from 'nestjs-mtenant';

@MTApi()
@Controller()
export class BooksController {
  // If "includeQuery=true" parameter specified, it will allow
  // setting tenant via MT_QUERY_PARAMETER_NAME query param.
  @MTApi({ includeQuery: true })
  someAction() {  }
}

Tenancy scope taken from the transport specified will be injected into instances and queries. If allowMissingTenant=true specified- queries will select entries for both- the tenant and missing tenant.

Switch model tenancy globally:

BookModel.switchTenancy(/* enabled =*/ false)

Switch model tenancy for a single operation (e.g. super-admin related ops):

// supports any operation supporting option parameter, incl. bulk ones
BookModel.create({...}, { disableTenancy: true });
// for complex operations with includes
UserModel.findAll({
  disableTenancy: true,
  include: [ { model: BookModel } ],
});
// ... or disable only for BookModel
UserModel.findAll({
  include: [ { model: BookModel, disableTenancy: true } ],
});

Setting custom tenant or disabling tenancy for the current request scope:

import { MTService } from 'nestjs-mtenant';

@Injectable()
export class UsersService {
  constructor(
    @InjectModel(User) private userModel: typeof User,
    private readonly tenancyService: MTService,
  ) { }

  // Will enforce using it for subsequent model operations within the scope
  // e.g. (await BooksService.useCustomTenant('custom-one')).create(...)
  // This might be set in controllers as well, when taking from a DTO when
  // there's no option to use a header.
  // Optionally you might set MT_QUERY_PARAMETER_NAME query paremeter to achieve the same...
  async useCustomTenant(tenant: string) {
    await this.tenancyService.setTenant(tenant);
    return this;
  }

  // Disabling tenancy for the current request scope (e.g. controller)
  // You might need to disable tenancy without changing subsequent services logic
  async disableTenancy() {
    this.tenancyService.disableTenancyForCurrentScope();
    return this;
  }
}

Typical stored tenants manager service (if storage option configured):

// src/tenancy/tenant-settings.dto.ts
export class TenantSettingsDto {
  someSetting?: string,
  otherOne?: any,
}

// src/tenancy/tenancy.service.ts
import { Injectable } from '@nestjs/common';
import { MTService, StoredTenantEntity } from 'nestjs-mtenant';
import { TenantSettingsDto } from './tenant-settings.dto';

@Injectable()
export class TenancyService {
  constructor(
    private readonly tenancyService: MTService,
  ) { }

  async setting(name: keyof TenantSettingsDto, defaultValue: any): Promise<any> {
    const { tenant } = this.tenancyService.tenancyScope;

    if (tenant === this.tenancyService.defaultTenancyScope.tenant) {
      return defaultValue;
    }

    const tenantEntity = await this.get(tenant);

    if (!tenantEntity) {
      return defaultValue;
    }

    return (tenantEntity as StoredTenantEntity<TenantSettingsDto>).settings[name] || defaultValue;
  }

  async add(tenant: string, settings: TenantSettingsDto):
    Promise<StoredTenantEntity<TenantSettingsDto>> {
    return this.tenancyService.storage.add(tenant, settings);
  }

  async remove(tenant: string): Promise<number> {
    return this.tenancyService.storage.remove(tenant);
  }

  async exists(tenant: string): Promise<Boolean> {
    return this.tenancyService.storage.exists(tenant);
  }

  async updateSettings(tenant: string, settings: TenantSettingsDto):
    Promise<StoredTenantEntity<TenantSettingsDto>> {
    return this.tenancyService.storage.updateSettings(tenant, settings);
  }

  async get(tenant?: string):
    Promise<StoredTenantEntity<TenantSettingsDto> | Array<StoredTenantEntity<TenantSettingsDto>>> {
    return this.tenancyService.storage.get(tenant);
  }
}

Storage interface reference (where T === typeof TenantSettingsDto):

export interface TenantEntity<T> {
  tenant: string,
  settings: T,
}

export interface Storage<T> {
  add(tenant: string, settings?: T): Promise<TenantEntity<T>>;
  remove(tenant: string): Promise<number>;
  exists(tenant: string): Promise<Boolean>;
  updateSettings(tenant: string, settings: T): Promise<TenantEntity<T>>;
  get(tenant: string): Promise<TenantEntity<T>>;
}

nestjs-mtenant integrates pretty well with the nestjs-iacry module:

// models/user.model.ts
import { IACryEntity } from 'nestjs-iacry';
import { MTEntity, DEFAULT_TENANT } from 'nestjs-mtenant';

@MTEntity() 
@IACryEntity({ nameField: 'principal' })
@Table({})
export default class User extends Model<User> {
  id: string; // idField
  tenant: string; // tenantField
  role?: string;

  // Get principals like "root/admin:33"
  @Column(DataType.VIRTUAL)
  get principal() {
    return `${this.getDataValue('tenant') || DEFAULT_TENANT}/${this.getDataValue('role')}`;
  }
}

A typical example of nestjs-iacry policies would look like:

// Allow everything for admins from any tenant
[
  {
    Effect: Effect.ALLOW,
    Action: '!tenancy', // allow anything but tenancy related stuff
    Principal: `*/${UserRoles.Admin}`,
  },
  {
    Effect: Effect.ALLOW,
    Action: '*',
    Principal: `${DEFAULT_TENANT}/${UserRoles.Admin}`,
  },
]

Development

Running tests:

npm test

Releasing:

npm run format
npm run release # npm run patch|minor|major
npm run deploy

TODO

  • Add support for TypeORM
  • Add support for strategies (e.g. different database, table suffix)
  • Cover most of codebase w/ tests
  • Add comprehensive Documentation

Contributing

License

MIT