npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

nestjs-mtenant

v0.1.1

Published

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

Downloads

216

Readme

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