@flusys/nestjs-iam
v9.2.1
Published
Identity and Access Management (IAM) module for NestJS applications
Downloads
2,598
Maintainers
Readme
@flusys/nestjs-iam
Identity and Access Management for NestJS — RBAC, DIRECT, and FULL permission modes with caching and multi-tenant support.
Installation
npm install @flusys/nestjs-iam @flusys/nestjs-shared @flusys/nestjs-core1. Register the Module
Synchronous
Mode 1: Single Database
import { IAMModule } from '@flusys/nestjs-iam';
@Module({
imports: [
IAMModule.forRoot({
global: true,
includeController: true,
bootstrapAppConfig: {
databaseMode: 'single',
enableCompanyFeature: false,
permissionMode: 'RBAC', // 'RBAC' | 'DIRECT' | 'FULL' (default: 'FULL')
},
config: {
defaultDatabaseConfig: {
type: 'mysql',
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 3306),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
},
},
}),
],
})
export class AppModule {}Mode 2: Multi-Tenant
IAMModule.forRoot({
global: true,
includeController: true,
bootstrapAppConfig: {
databaseMode: 'multi-tenant',
enableCompanyFeature: true,
permissionMode: 'FULL',
},
config: {
tenantDefaultDatabaseConfig: {
type: 'mysql',
host: process.env.TENANT_DB_HOST,
port: Number(process.env.TENANT_DB_PORT ?? 3306),
username: process.env.TENANT_DB_USER,
password: process.env.TENANT_DB_PASSWORD,
database: process.env.TENANT_DB_NAME,
},
tenants: [
{ id: 'tenant-a', database: 'tenant_a_db', permissionMode: 'FULL' },
{ id: 'tenant-b', database: 'tenant_b_db', permissionMode: 'RBAC' },
],
},
});Asynchronous (with ConfigService)
import { ConfigModule, ConfigService } from '@nestjs/config';
import { IAMModule, ITenantDatabaseConfig } from '@flusys/nestjs-iam';
// Single database
IAMModule.forRootAsync({
global: true,
includeController: true,
bootstrapAppConfig: {
databaseMode: 'single',
enableCompanyFeature: true,
permissionMode: 'FULL',
},
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
defaultDatabaseConfig: {
type: 'mysql',
host: configService.get('DB_HOST'),
port: configService.get<number>('DB_PORT'),
username: configService.get('DB_USER'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_NAME'),
},
}),
inject: [ConfigService],
});
// Multi-tenant
IAMModule.forRootAsync({
global: true,
includeController: true,
bootstrapAppConfig: {
databaseMode: 'multi-tenant',
enableCompanyFeature: true,
permissionMode: 'FULL',
},
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
tenantDefaultDatabaseConfig: {
type: 'mysql',
host: configService.get('TENANT_DB_HOST'),
port: configService.get<number>('TENANT_DB_PORT'),
username: configService.get('TENANT_DB_USER'),
password: configService.get('TENANT_DB_PASSWORD'),
database: configService.get('TENANT_DB_NAME'),
},
tenants: configService.get<ITenantDatabaseConfig[]>('TENANTS'),
}),
inject: [ConfigService],
});2. Register Entities in TypeORM
Use getIAMEntitiesByConfig() — arguments must match bootstrapAppConfig:
import { getIAMEntitiesByConfig } from '@flusys/nestjs-iam/entities';
TypeOrmModule.forRoot({
entities: [
...getIAMEntitiesByConfig(
true, // enableCompanyFeature
'FULL', // permissionMode: 'FULL' | 'RBAC' | 'DIRECT'
),
],
});3. Protect Endpoints
All FLUSYS endpoints use POST. Apply JwtAuthGuard and @RequirePermission from nestjs-shared:
import { JwtAuthGuard } from '@flusys/nestjs-shared/guards';
import { RequirePermission, CurrentUser } from '@flusys/nestjs-shared/decorators';
import { ILoggedUserInfo } from '@flusys/nestjs-shared/interfaces';
@UseGuards(JwtAuthGuard)
@Controller('products')
export class ProductController {
@Post('insert')
@RequirePermission('product.create')
async create(@CurrentUser() user: ILoggedUserInfo) {
/* ... */
}
@Post('get-all')
@RequirePermission('product.read')
async getAll(@CurrentUser() user: ILoggedUserInfo) {
/* ... */
}
}Wildcard matching is supported: @RequirePermission('product.*') matches any action whose code starts with product..
4. Programmatic Permission Check
Inject PermissionService (use @Inject() — required for bundled code):
import { PermissionService } from '@flusys/nestjs-iam';
@Injectable()
export class ProductService {
constructor(@Inject(PermissionService) private readonly permissionService: PermissionService) {}
async canCreate(userId: string, companyId?: string, branchId?: string): Promise<boolean> {
return this.permissionService.hasPermission(userId, 'product.create', companyId, branchId);
}
}hasPermission checks the user's backend codes with matchesPermission() from @flusys/nestjs-shared/utils (* and prefix.* wildcards).
For other packages: PERMISSION_RESOLVER
IAMModule provides and exports the PERMISSION_RESOLVER provider interface from @flusys/nestjs-shared (PermissionResolverAdapter). getPermissionCodes({ userId, companyId?, branchId? }) returns the user's backend codes for any branch - cached, or rebuilt from roles and direct grants and cached (PermissionCacheService.getBackendCodes). Feature packages inject it @Optional() (nestjs-entity-builder uses it for HAS_PERMISSION(...) rules) and never import nestjs-iam.
PERMISSION_ACTION_REGISTRY (PermissionActionRegistryAdapter) lets a feature package create, revive and remove Actions at runtime. Both adapters are singletons that resolve the request-scoped services per call (iam.module.scope.spec.ts guards this).
5. Permission Resolution Rules
- RBAC: user roles -> role actions. DIRECT: direct user actions. FULL: both, merged.
- Only active, non-deleted actions count, only active, non-deleted roles count, and grants outside
validFrom/validUntilare ignored. - Company feature on: a session sees its company's company-wide grants plus its branch's (all of its branches when it has no branch); a session without a company sees only company-less grants, never every company's. A role only grants inside its own company.
- The permission cache (one version stamp per user, see nestjs-shared README section 7) is dropped after every change that affects it, always after the write commits, for every user it reaches - computed before any grant row is deleted: assignments, role update / delete, action update / delete (a permanent delete also covers the child actions the parent FK cascades to), company whitelist removals (the whitelist itself only limits what may be assigned; removing an action from it deletes the company's role and direct grants of it), company / branch / user access revocation, and actions registered or removed through
PERMISSION_ACTION_REGISTRY. - A cached permission set expires no later than the next
validFrom/validUntilboundary of the grants it was built from, so a time-bound grant starts and stops counting on time. - Actions and roles written outside
ActionService/RoleService(the action registry, company revocation) bump that entity's ApiService cache themselves.
6. Assignment Rules
The assignment endpoints pass the caller to PermissionService (assignUserActions(dto, actor), assignUserRoles(dto, actor), assignRoleActions(dto, actor)); a call without an actor is a trusted system call.
- Company feature on: the grant's company must be the caller's own (it defaults to it when omitted), roles must belong to that company, actions must be on that company's whitelist (
company-actions/assign), and - when nestjs-auth'sCOMPANY_ACCESS_RESOLVERis available - the target user (and branch) must belong to that company. Reads (get-user-actions,get-user-roles,get-role-users,get-action-users,get-role-actions,actions/tree-for-permission) are limited to the caller's company the same way. - No self-escalation: nobody can grant themselves an action, or a role carrying an action, they do not already hold, nor add an action to a role they hold (
403 error.insufficient.permissions). - Cross-company assignment (onboarding a new company):
user-actions/assign,user-roles/assign,get-user-actionsandget-user-rolesaccept anothercompanyIdonly when the caller holdscross-company.assign(CROSS_COMPANY_PERMISSIONS.ASSIGN) in their own session scope - an explicit grant, never matched by*orcross-company.*. Every other check still runs against the target company (its roles, its whitelist, target user membership), and every action granted there - directly or through a role, to anyone - must be one the caller holds. Without the grant the call is refused with403 auth.company.no.access. The seed gives it to the admin; grant it only to platform/onboarding admins. - Unknown or deleted actions / roles are refused (
404); a concurrent duplicate assignment is a409 permission.already.existson PostgreSQL, MySQL, SQLite and SQL Server. - Roles: company users only create, read, update and delete roles of their own company.
Exported Services
| Service | Scope | Description |
| ------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------- |
| PermissionService | REQUEST | hasPermission(), getMyPermissions(), assign*(), getUserRoles(), getUserActions(), getUserEffectiveRoles(), getUserEffectiveActions(), revoke*() |
| PermissionCacheService | REQUEST | getBackendCodes(), getHeldActions(), invalidateUser(s)(), invalidateRoleMembersCache(), findActionHolderIds(), findRoleMemberIds(), findCompanyMemberIds() |
| RoleService | REQUEST | Role CRUD (RBAC / FULL only), company scoped |
| ActionService | REQUEST | Action CRUD, getActionTree(), getActionsForPermission() |
All constructor injections need explicit @Inject() — TypeScript metadata is lost during esbuild bundling.
License
MIT © FLUSYS
