@lenne.tech/nest-server 11.20.1 → 11.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
  2. package/dist/core/common/decorators/restricted.decorator.js +4 -1
  3. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  4. package/dist/core/common/interceptors/check-security.interceptor.js +5 -1
  5. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  6. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -1
  7. package/dist/core/common/middleware/request-context.middleware.js +10 -6
  8. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  9. package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
  10. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  11. package/dist/core/common/services/request-context.service.d.ts +3 -0
  12. package/dist/core/common/services/request-context.service.js.map +1 -1
  13. package/dist/core/modules/auth/guards/roles.guard.js +6 -10
  14. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  15. package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
  16. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  17. package/dist/core/modules/tenant/core-tenant-member.model.d.ts +11 -0
  18. package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
  19. package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
  20. package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
  21. package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
  22. package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
  23. package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
  24. package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
  25. package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
  26. package/dist/core/modules/tenant/core-tenant.guard.d.ts +13 -0
  27. package/dist/core/modules/tenant/core-tenant.guard.js +162 -0
  28. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
  29. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
  30. package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
  31. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
  32. package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
  33. package/dist/core/modules/tenant/core-tenant.module.js +58 -0
  34. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
  35. package/dist/core/modules/tenant/core-tenant.service.d.ts +17 -0
  36. package/dist/core/modules/tenant/core-tenant.service.js +160 -0
  37. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
  38. package/dist/core.module.js +11 -0
  39. package/dist/core.module.js.map +1 -1
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.js +7 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/tsconfig.build.tsbuildinfo +1 -1
  44. package/package.json +12 -10
  45. package/src/core/common/decorators/restricted.decorator.ts +12 -2
  46. package/src/core/common/interceptors/check-security.interceptor.ts +9 -2
  47. package/src/core/common/interfaces/server-options.interface.ts +63 -30
  48. package/src/core/common/middleware/request-context.middleware.ts +12 -5
  49. package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
  50. package/src/core/common/services/request-context.service.ts +7 -1
  51. package/src/core/modules/auth/guards/roles.guard.ts +10 -10
  52. package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
  53. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +165 -0
  54. package/src/core/modules/tenant/README.md +232 -0
  55. package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
  56. package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
  57. package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
  58. package/src/core/modules/tenant/core-tenant.guard.ts +240 -0
  59. package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
  60. package/src/core/modules/tenant/core-tenant.module.ts +102 -0
  61. package/src/core/modules/tenant/core-tenant.service.ts +235 -0
  62. package/src/core.module.ts +15 -0
  63. package/src/index.ts +12 -0
@@ -0,0 +1,240 @@
1
+ import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger } from '@nestjs/common';
2
+ import { Reflector } from '@nestjs/core';
3
+ import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
4
+ import { InjectModel } from '@nestjs/mongoose';
5
+ import { Model } from 'mongoose';
6
+
7
+ import { RoleEnum } from '../../common/enums/role.enum';
8
+ import { ConfigService } from '../../common/services/config.service';
9
+ import { CoreTenantMemberModel } from './core-tenant-member.model';
10
+ import { SKIP_TENANT_CHECK_KEY } from './core-tenant.decorators';
11
+ import { TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums';
12
+ import {
13
+ checkRoleAccess,
14
+ getMinRequiredLevel,
15
+ getRoleHierarchy,
16
+ isSystemRole,
17
+ mergeRolesMetadata,
18
+ } from './core-tenant.helpers';
19
+
20
+ /**
21
+ * Global guard for multi-tenancy with defense-in-depth security.
22
+ *
23
+ * Only registered as APP_GUARD when multiTenancy is active.
24
+ * Runs after auth guards to validate tenant membership and role access.
25
+ *
26
+ * This guard is responsible for ALL non-system role checks when multiTenancy is active.
27
+ * RolesGuard passes through non-system roles to this guard.
28
+ *
29
+ * Security model:
30
+ * - Guard level: Membership validation, admin bypass, role checks (hierarchy + normal)
31
+ * - Plugin level: Safety net — ForbiddenException when tenantId-schema accessed without context
32
+ *
33
+ * Role check semantics:
34
+ * - Hierarchy roles (in roleHierarchy config): level comparison — higher includes lower
35
+ * - Normal roles (not in roleHierarchy): exact match — no compensation by higher role
36
+ * - Tenant context (header present): checks against membership.role only (user.roles ignored)
37
+ * - No tenant context: checks against user.roles
38
+ *
39
+ * Flow:
40
+ * 1. Config check: multiTenancy enabled?
41
+ * 2. Parse header (X-Tenant-Id, max 128 chars, trimmed)
42
+ * 3. @SkipTenantCheck → role check against user.roles, no tenant context
43
+ * 4. Read @Roles() metadata, filter out system roles
44
+ *
45
+ * HEADER PRESENT:
46
+ * - System ADMIN (adminBypass: true) → set req.tenantId + isAdminBypass
47
+ * - No user → 403 "Authentication required for tenant access"
48
+ * - Authenticated non-admin user:
49
+ * - Active member → checkRoleAccess against membership.role → set req.tenantId + tenantRole
50
+ * - Not active member → ALWAYS 403
51
+ *
52
+ * NO HEADER:
53
+ * - System ADMIN → set isAdminBypass (sees all data)
54
+ * - Authenticated + checkable roles → checkRoleAccess against user.roles
55
+ * → resolveUserTenantIds with minLevel filter
56
+ * - Authenticated + no checkable roles → resolveUserTenantIds (all memberships)
57
+ * - No user + no checkable roles → pass (plugin safety net catches tenantId-schema access)
58
+ * - No user + checkable roles → 403 "Authentication required"
59
+ */
60
+ @Injectable()
61
+ export class CoreTenantGuard implements CanActivate {
62
+ private readonly logger = new Logger(CoreTenantGuard.name);
63
+
64
+ constructor(
65
+ private readonly reflector: Reflector,
66
+ @InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
67
+ ) {}
68
+
69
+ async canActivate(context: ExecutionContext): Promise<boolean> {
70
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
71
+ if (!config || config.enabled === false) {
72
+ return true;
73
+ }
74
+
75
+ const request = this.getRequest(context);
76
+ if (!request) {
77
+ return true;
78
+ }
79
+
80
+ // Parse tenant header
81
+ const headerName = (config.headerName ?? 'x-tenant-id').toLowerCase();
82
+ const rawHeader = request.headers?.[headerName] as string | undefined;
83
+ const headerTenantId =
84
+ rawHeader && typeof rawHeader === 'string' && rawHeader.length <= 128 ? rawHeader.trim() : undefined;
85
+
86
+ // Read @Roles() metadata and filter to non-system roles
87
+ const rolesMetadata = this.reflector.getAll<string[][]>('roles', [context.getHandler(), context.getClass()]);
88
+ const roles = mergeRolesMetadata(rolesMetadata);
89
+ const checkableRoles = roles.filter((r) => !isSystemRole(r));
90
+ const minRequiredLevel = getMinRequiredLevel(checkableRoles);
91
+
92
+ const user = request.user;
93
+ const adminBypass = config.adminBypass !== false;
94
+ const isAdmin = adminBypass && user?.roles?.includes(RoleEnum.ADMIN);
95
+
96
+ // @SkipTenantCheck → no tenant context, but role check against user.roles
97
+ const skipTenantCheck = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
98
+ context.getHandler(),
99
+ context.getClass(),
100
+ ]);
101
+ if (skipTenantCheck) {
102
+ if (checkableRoles.length > 0 && user) {
103
+ if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
104
+ throw new ForbiddenException('Insufficient role');
105
+ }
106
+ }
107
+ return true;
108
+ }
109
+
110
+ // === HEADER PRESENT ===
111
+ if (headerTenantId) {
112
+ // Admin bypass: set req.tenantId so plugin filters (read by RequestContextMiddleware
113
+ // lazy getter → context.tenantId, also consumed by @CurrentTenant() via RequestContext)
114
+ if (isAdmin) {
115
+ request.tenantId = headerTenantId;
116
+ request.isAdminBypass = true;
117
+ const requiredRole = checkableRoles.length > 0 ? checkableRoles.join(',') : 'none';
118
+ this.logger.log(`Admin bypass: user ${user.id} accessing tenant ${headerTenantId} (required: ${requiredRole})`);
119
+ return true;
120
+ }
121
+
122
+ // No user + header → 403 (tenant access requires authentication)
123
+ if (!user) {
124
+ throw new ForbiddenException('Authentication required for tenant access');
125
+ }
126
+
127
+ // Authenticated non-admin user: MUST be active member
128
+ const membership = await this.memberModel
129
+ .findOne({
130
+ status: TenantMemberStatus.ACTIVE,
131
+ tenant: headerTenantId,
132
+ user: user.id,
133
+ })
134
+ .lean()
135
+ .exec();
136
+
137
+ if (!membership) {
138
+ throw new ForbiddenException('Not a member of this tenant');
139
+ }
140
+
141
+ const memberRole = membership.role as string;
142
+
143
+ // Check role access if roles are required (hierarchy + normal, against membership.role)
144
+ if (checkableRoles.length > 0) {
145
+ if (!checkRoleAccess(checkableRoles, undefined, memberRole)) {
146
+ throw new ForbiddenException('Insufficient tenant role');
147
+ }
148
+ }
149
+
150
+ // Set validated tenant context on request (consumed by RequestContextMiddleware
151
+ // lazy getter → context.tenantId / context.tenantRole, and by @CurrentTenant() via RequestContext)
152
+ request.tenantId = headerTenantId;
153
+ request.tenantRole = memberRole;
154
+ return true;
155
+ }
156
+
157
+ // === NO HEADER ===
158
+
159
+ // Admin without header → sees all data
160
+ if (isAdmin) {
161
+ request.isAdminBypass = true;
162
+ return true;
163
+ }
164
+
165
+ // Checkable roles present
166
+ if (checkableRoles.length > 0) {
167
+ // No user + roles required → 403
168
+ if (!user) {
169
+ throw new ForbiddenException('Authentication required');
170
+ }
171
+
172
+ // Check role access against user.roles (hierarchy: level comparison, normal: exact match)
173
+ if (!checkRoleAccess(checkableRoles, user.roles, undefined)) {
174
+ throw new ForbiddenException('Insufficient role');
175
+ }
176
+
177
+ // Resolve tenant IDs filtered by minimum required hierarchy level
178
+ await this.resolveUserTenantIds(request, minRequiredLevel);
179
+ return true;
180
+ }
181
+
182
+ // Authenticated user without header and no checkable roles: resolve their tenant memberships
183
+ // so the plugin can filter by { tenantId: { $in: tenantIds } }
184
+ if (user) {
185
+ await this.resolveUserTenantIds(request);
186
+ }
187
+
188
+ return true;
189
+ }
190
+
191
+ /**
192
+ * Look up all active tenant memberships for the user and store them on the request.
193
+ * This allows the tenant plugin to filter by { tenantId: { $in: tenantIds } }
194
+ * when no specific tenant header is set.
195
+ *
196
+ * @param minLevel - When set, only include memberships where role level >= minLevel
197
+ */
198
+ private async resolveUserTenantIds(request: any, minLevel?: number): Promise<void> {
199
+ // Skip if already resolved (request-scoped caching)
200
+ if (request.tenantIds) {
201
+ return;
202
+ }
203
+
204
+ const memberships = await this.memberModel
205
+ .find({
206
+ status: TenantMemberStatus.ACTIVE,
207
+ user: request.user.id,
208
+ })
209
+ .select('tenant role')
210
+ .lean()
211
+ .exec();
212
+
213
+ if (minLevel !== undefined) {
214
+ const hierarchy = getRoleHierarchy();
215
+ request.tenantIds = memberships
216
+ .filter((m) => {
217
+ const level = hierarchy[m.role as string] ?? 0;
218
+ return level >= minLevel;
219
+ })
220
+ .map((m) => m.tenant);
221
+ } else {
222
+ request.tenantIds = memberships.map((m) => m.tenant);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Extract request from GraphQL or HTTP context
228
+ */
229
+ private getRequest(context: ExecutionContext): any {
230
+ if (context.getType<GqlContextType>() === 'graphql') {
231
+ const ctx = GqlExecutionContext.create(context);
232
+ return ctx.getContext()?.req;
233
+ }
234
+ try {
235
+ return context.switchToHttp().getRequest();
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+ }
@@ -0,0 +1,103 @@
1
+ import { ConfigService } from '../../common/services/config.service';
2
+ import { DEFAULT_ROLE_HIERARCHY } from './core-tenant.enums';
3
+
4
+ const SYSTEM_ROLE_PREFIX = 's_';
5
+
6
+ /**
7
+ * Merge handler-level and class-level @Roles() metadata arrays into a single flat array.
8
+ * Used by RolesGuard, BetterAuthRolesGuard, and CoreTenantGuard to avoid code duplication.
9
+ *
10
+ * @param meta - Two-element tuple [handlerRoles, classRoles] from Reflector.getAll or Reflect.getMetadata
11
+ */
12
+ export function mergeRolesMetadata(meta: (string[] | undefined)[]): string[] {
13
+ return meta[0] ? (meta[1] ? [...meta[0], ...meta[1]] : meta[0]) : meta[1] || [];
14
+ }
15
+
16
+ /**
17
+ * Get the configured role hierarchy or the default.
18
+ */
19
+ export function getRoleHierarchy(): Record<string, number> {
20
+ return ConfigService.configFastButReadOnly?.multiTenancy?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY;
21
+ }
22
+
23
+ /**
24
+ * Check if a role is a system role (S_USER, S_EVERYONE, etc.).
25
+ * System roles are handled by RolesGuard, not CoreTenantGuard.
26
+ */
27
+ export function isSystemRole(role: string): boolean {
28
+ return role.startsWith(SYSTEM_ROLE_PREFIX);
29
+ }
30
+
31
+ /**
32
+ * Check if multiTenancy is configured and enabled.
33
+ */
34
+ export function isMultiTenancyActive(): boolean {
35
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
36
+ return !!config && config.enabled !== false;
37
+ }
38
+
39
+ /**
40
+ * Check if a role is a hierarchy role (present in the configured role hierarchy).
41
+ * Returns false when multiTenancy is disabled to avoid false positives.
42
+ */
43
+ export function isHierarchyRole(role: string): boolean {
44
+ if (!isMultiTenancyActive()) return false;
45
+ const hierarchy = getRoleHierarchy();
46
+ return role in hierarchy;
47
+ }
48
+
49
+ /**
50
+ * Get the minimum required level from a set of roles.
51
+ * Only considers roles that exist in the hierarchy.
52
+ * Returns undefined if no hierarchy roles found.
53
+ */
54
+ export function getMinRequiredLevel(roles: string[]): number | undefined {
55
+ const hierarchy = getRoleHierarchy();
56
+ const levels = roles.filter((r) => r in hierarchy).map((r) => hierarchy[r]);
57
+ if (levels.length === 0) return undefined;
58
+ return Math.min(...levels);
59
+ }
60
+
61
+ /**
62
+ * Unified role access check for both tenant and non-tenant context.
63
+ * Handles hierarchy roles (level comparison) AND normal roles (exact match).
64
+ *
65
+ * @param requiredRoles - roles from @Roles/@Restricted (system roles should be filtered out by caller)
66
+ * @param userRoles - user.roles array (used when no tenantRole)
67
+ * @param tenantRole - membership.role (used when tenant context active)
68
+ *
69
+ * When tenantRole is set: checks against [tenantRole] (tenant overrides user.roles)
70
+ * When no tenantRole: checks against userRoles
71
+ *
72
+ * Hierarchy roles → level comparison (higher includes lower)
73
+ * Normal roles → exact match (no compensation by higher role)
74
+ *
75
+ * OR semantics: any match (hierarchy OR normal) is sufficient.
76
+ */
77
+ export function checkRoleAccess(requiredRoles: string[], userRoles?: string[], tenantRole?: string): boolean {
78
+ const availableRoles = tenantRole ? [tenantRole] : (userRoles ?? []);
79
+ if (availableRoles.length === 0) return false;
80
+
81
+ // When multiTenancy is disabled, treat all roles as normal (exact match only)
82
+ const multiTenancyActive = isMultiTenancyActive();
83
+ const hierarchy = multiTenancyActive ? getRoleHierarchy() : {};
84
+ const hierarchyRequired = requiredRoles.filter((r) => r in hierarchy);
85
+ const nonHierarchyRequired = requiredRoles.filter((r) => !(r in hierarchy));
86
+
87
+ if (hierarchyRequired.length === 0 && nonHierarchyRequired.length === 0) return true;
88
+
89
+ // OR semantics: any category match is sufficient
90
+
91
+ // Hierarchy roles: level comparison (higher includes lower)
92
+ if (hierarchyRequired.length > 0) {
93
+ const minRequired = Math.min(...hierarchyRequired.map((r) => hierarchy[r]));
94
+ if (availableRoles.some((r) => r in hierarchy && hierarchy[r] >= minRequired)) return true;
95
+ }
96
+
97
+ // Non-hierarchy roles: exact match
98
+ if (nonHierarchyRequired.length > 0) {
99
+ if (nonHierarchyRequired.some((r) => availableRoles.includes(r))) return true;
100
+ }
101
+
102
+ return false;
103
+ }
@@ -0,0 +1,102 @@
1
+ import { CanActivate, DynamicModule, Global, Module, Type } from '@nestjs/common';
2
+ import { APP_GUARD } from '@nestjs/core';
3
+ import { MongooseModule, SchemaFactory, getModelToken } from '@nestjs/mongoose';
4
+ import { Model } from 'mongoose';
5
+
6
+ import { CoreTenantMemberModel } from './core-tenant-member.model';
7
+ import { TENANT_MEMBER_MODEL_TOKEN } from './core-tenant.enums';
8
+ import { CoreTenantGuard } from './core-tenant.guard';
9
+ import { CoreTenantService } from './core-tenant.service';
10
+
11
+ /**
12
+ * Options for CoreTenantModule.forRoot().
13
+ *
14
+ * Projects using auto-registration via `multiTenancy: {}` get default implementations.
15
+ * For custom model/guard/service, use `CoreTenantModule.forRoot({ ... })` directly
16
+ * in your ServerModule instead of auto-registration.
17
+ */
18
+ export interface CoreTenantModuleOptions {
19
+ /** Custom TenantMember model class (must extend CoreTenantMemberModel) */
20
+ memberModel?: Type<CoreTenantMemberModel>;
21
+ /** Custom guard class (must implement CanActivate) */
22
+ guard?: Type<CanActivate>;
23
+ /** Custom service class (must extend CoreTenantService) */
24
+ service?: Type<CoreTenantService>;
25
+ /**
26
+ * Mongoose model name for the membership collection.
27
+ * Defaults to 'TenantMember'. When changed, an alias is created so that
28
+ * @InjectModel('TenantMember') continues to work in guard and service.
29
+ * @default 'TenantMember'
30
+ */
31
+ modelName?: string;
32
+ }
33
+
34
+ /**
35
+ * Core tenant module for multi-tenancy support.
36
+ *
37
+ * Provides:
38
+ * - TenantMember model (user <-> tenant membership with roles)
39
+ * - CoreTenantGuard (APP_GUARD for X-Tenant-Id header validation)
40
+ * - CoreTenantService (membership CRUD operations)
41
+ *
42
+ * Projects can extend via the Module Inheritance Pattern by passing custom
43
+ * model, guard, or service classes to forRoot().
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * // Auto-registration via CoreModule config
48
+ * CoreModule.forRoot({ multiTenancy: {} })
49
+ *
50
+ * // Manual registration with custom service
51
+ * CoreTenantModule.forRoot({ service: CustomTenantService })
52
+ *
53
+ * // Custom model name (config.multiTenancy.membershipModel)
54
+ * CoreTenantModule.forRoot({ modelName: 'OrgMember' })
55
+ * ```
56
+ */
57
+ @Global()
58
+ @Module({})
59
+ export class CoreTenantModule {
60
+ static forRoot(options: CoreTenantModuleOptions = {}): DynamicModule {
61
+ const MemberModel = options.memberModel || CoreTenantMemberModel;
62
+ const Guard = options.guard || CoreTenantGuard;
63
+ const Service = options.service || CoreTenantService;
64
+ const modelName = options.modelName || TENANT_MEMBER_MODEL_TOKEN;
65
+
66
+ const memberSchema = SchemaFactory.createForClass(MemberModel);
67
+
68
+ // Compound unique index: one membership per user per tenant
69
+ memberSchema.index({ user: 1, tenant: 1 }, { unique: true });
70
+ // Compound index for per-request membership lookups (index-covered query)
71
+ memberSchema.index({ user: 1, tenant: 1, status: 1 });
72
+
73
+ const providers: any[] = [
74
+ {
75
+ provide: CoreTenantService,
76
+ useClass: Service,
77
+ },
78
+ {
79
+ provide: APP_GUARD,
80
+ useClass: Guard,
81
+ },
82
+ ];
83
+
84
+ // When a custom model name is used, alias the default injection token to the custom model.
85
+ // This allows @InjectModel(TENANT_MEMBER_MODEL_TOKEN) in guard/service to continue working.
86
+ if (modelName !== TENANT_MEMBER_MODEL_TOKEN) {
87
+ providers.push({
88
+ provide: getModelToken(TENANT_MEMBER_MODEL_TOKEN),
89
+ useFactory: (model: Model<any>) => model,
90
+ inject: [getModelToken(modelName)],
91
+ });
92
+ }
93
+
94
+ return {
95
+ exports: [CoreTenantService],
96
+ global: true,
97
+ imports: [MongooseModule.forFeature([{ name: modelName, schema: memberSchema }])],
98
+ module: CoreTenantModule,
99
+ providers,
100
+ };
101
+ }
102
+ }
@@ -0,0 +1,235 @@
1
+ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
2
+ import { InjectModel } from '@nestjs/mongoose';
3
+ import { Model } from 'mongoose';
4
+
5
+ import { ConfigService } from '../../common/services/config.service';
6
+ import { RequestContext } from '../../common/services/request-context.service';
7
+ import { CoreTenantMemberModel } from './core-tenant-member.model';
8
+ import { DEFAULT_ROLE_HIERARCHY, TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums';
9
+
10
+ /**
11
+ * Core service for tenant membership operations.
12
+ *
13
+ * Projects should extend this service via the Module Inheritance Pattern
14
+ * to add custom logic (e.g., tenant creation, invitation flows).
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * @Injectable()
19
+ * export class TenantService extends CoreTenantService {
20
+ * override async addMember(tenantId: string, userId: string, role?: string) {
21
+ * const member = await super.addMember(tenantId, userId, role);
22
+ * // Custom: send notification email
23
+ * await this.notificationService.sendInvite(userId, tenantId);
24
+ * return member;
25
+ * }
26
+ * }
27
+ * ```
28
+ */
29
+ @Injectable()
30
+ export class CoreTenantService {
31
+ protected readonly logger = new Logger(CoreTenantService.name);
32
+
33
+ constructor(@InjectModel(TENANT_MEMBER_MODEL_TOKEN) protected readonly memberModel: Model<CoreTenantMemberModel>) {}
34
+
35
+ /**
36
+ * Get the configured role hierarchy.
37
+ */
38
+ protected getHierarchy(): Record<string, number> {
39
+ return ConfigService.configFastButReadOnly?.multiTenancy?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY;
40
+ }
41
+
42
+ /**
43
+ * Get the default (lowest) role name from the hierarchy.
44
+ */
45
+ protected getDefaultRole(): string {
46
+ const hierarchy = this.getHierarchy();
47
+ const entries = Object.entries(hierarchy);
48
+ if (entries.length === 0) return 'member';
49
+ return entries.reduce((a, b) => (a[1] <= b[1] ? a : b))[0];
50
+ }
51
+
52
+ /**
53
+ * Get the highest role name from the hierarchy.
54
+ */
55
+ protected getHighestRole(): string {
56
+ const hierarchy = this.getHierarchy();
57
+ const entries = Object.entries(hierarchy);
58
+ if (entries.length === 0) return 'owner';
59
+ return entries.reduce((a, b) => (a[1] >= b[1] ? a : b))[0];
60
+ }
61
+
62
+ /**
63
+ * Find all active tenant memberships for a user.
64
+ */
65
+ async findMemberships(userId: string): Promise<CoreTenantMemberModel[]> {
66
+ return this.memberModel.find({ status: TenantMemberStatus.ACTIVE, user: userId }).lean().exec() as Promise<
67
+ CoreTenantMemberModel[]
68
+ >;
69
+ }
70
+
71
+ /**
72
+ * Get a single membership (any status).
73
+ */
74
+ async getMembership(tenantId: string, userId: string): Promise<CoreTenantMemberModel | null> {
75
+ return this.memberModel
76
+ .findOne({ tenant: tenantId, user: userId })
77
+ .lean()
78
+ .exec() as Promise<CoreTenantMemberModel | null>;
79
+ }
80
+
81
+ /**
82
+ * Add a member to a tenant.
83
+ * Uses bypassTenantGuard to avoid tenant filtering on the membership collection itself.
84
+ *
85
+ * @param role - Role name from the configured hierarchy. Defaults to the lowest role.
86
+ */
87
+ async addMember(
88
+ tenantId: string,
89
+ userId: string,
90
+ role?: string,
91
+ invitedById?: string,
92
+ ): Promise<CoreTenantMemberModel> {
93
+ if (!tenantId?.trim()) {
94
+ throw new BadRequestException('tenantId must not be empty');
95
+ }
96
+ if (!userId?.trim()) {
97
+ throw new BadRequestException('userId must not be empty');
98
+ }
99
+ const effectiveRole = role ?? this.getDefaultRole();
100
+
101
+ // Check for existing membership
102
+ const existing = await this.getMembership(tenantId, userId);
103
+ if (existing) {
104
+ if (existing.status === TenantMemberStatus.ACTIVE) {
105
+ throw new BadRequestException('User is already an active member of this tenant');
106
+ }
107
+ // Reactivate suspended/invited membership
108
+ return RequestContext.runWithBypassTenantGuard(async () => {
109
+ return this.memberModel
110
+ .findOneAndUpdate(
111
+ { tenant: tenantId, user: userId },
112
+ {
113
+ invitedBy: invitedById,
114
+ joinedAt: new Date(),
115
+ role: effectiveRole,
116
+ status: TenantMemberStatus.ACTIVE,
117
+ },
118
+ { new: true },
119
+ )
120
+ .lean()
121
+ .exec() as Promise<CoreTenantMemberModel>;
122
+ });
123
+ }
124
+
125
+ return RequestContext.runWithBypassTenantGuard(async () => {
126
+ const doc = await this.memberModel.create({
127
+ invitedBy: invitedById,
128
+ joinedAt: new Date(),
129
+ role: effectiveRole,
130
+ status: TenantMemberStatus.ACTIVE,
131
+ tenant: tenantId,
132
+ user: userId,
133
+ });
134
+ return doc.toObject() as CoreTenantMemberModel;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Remove a member from a tenant (sets status to SUSPENDED).
140
+ * Prevents removing the last owner (highest role).
141
+ */
142
+ async removeMember(tenantId: string, userId: string): Promise<CoreTenantMemberModel> {
143
+ if (!tenantId?.trim()) {
144
+ throw new BadRequestException('tenantId must not be empty');
145
+ }
146
+ if (!userId?.trim()) {
147
+ throw new BadRequestException('userId must not be empty');
148
+ }
149
+ await this.assertNotLastOwner(tenantId, userId);
150
+
151
+ return RequestContext.runWithBypassTenantGuard(async () => {
152
+ const result = await this.memberModel
153
+ .findOneAndUpdate(
154
+ { status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId },
155
+ { status: TenantMemberStatus.SUSPENDED },
156
+ { new: true },
157
+ )
158
+ .lean()
159
+ .exec();
160
+
161
+ if (!result) {
162
+ throw new NotFoundException('Membership not found');
163
+ }
164
+
165
+ return result as CoreTenantMemberModel;
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Update a member's role within a tenant.
171
+ * Prevents demoting the last owner (highest role).
172
+ */
173
+ async updateMemberRole(tenantId: string, userId: string, role: string): Promise<CoreTenantMemberModel> {
174
+ if (!tenantId?.trim()) {
175
+ throw new BadRequestException('tenantId must not be empty');
176
+ }
177
+ if (!userId?.trim()) {
178
+ throw new BadRequestException('userId must not be empty');
179
+ }
180
+ if (!role?.trim()) {
181
+ throw new BadRequestException('role must not be empty');
182
+ }
183
+ const highestRole = this.getHighestRole();
184
+
185
+ // If demoting from highest role, ensure it's not the last one
186
+ const existing = await this.getMembership(tenantId, userId);
187
+ if (existing?.role === highestRole && role !== highestRole) {
188
+ await this.assertNotLastOwner(tenantId, userId);
189
+ }
190
+
191
+ return RequestContext.runWithBypassTenantGuard(async () => {
192
+ const result = await this.memberModel
193
+ .findOneAndUpdate(
194
+ { status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId },
195
+ { role },
196
+ { new: true },
197
+ )
198
+ .lean()
199
+ .exec();
200
+
201
+ if (!result) {
202
+ throw new NotFoundException('Active membership not found');
203
+ }
204
+
205
+ return result as CoreTenantMemberModel;
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Ensure the given user is not the last owner (highest role) of the tenant.
211
+ * Throws BadRequestException if removing/demoting them would leave the tenant without an owner.
212
+ *
213
+ * Note: This uses a read-check-act pattern which has a theoretical TOCTOU race under
214
+ * concurrent requests. For production environments with high concurrency, consider using
215
+ * MongoDB transactions (requires replica set) in your extended service.
216
+ */
217
+ async assertNotLastOwner(tenantId: string, userId: string): Promise<void> {
218
+ const highestRole = this.getHighestRole();
219
+
220
+ return RequestContext.runWithBypassTenantGuard(async () => {
221
+ const ownerCount = await this.memberModel.countDocuments({
222
+ role: highestRole,
223
+ status: TenantMemberStatus.ACTIVE,
224
+ tenant: tenantId,
225
+ });
226
+
227
+ if (ownerCount <= 1) {
228
+ const membership = await this.getMembership(tenantId, userId);
229
+ if (membership?.role === highestRole) {
230
+ throw new BadRequestException('Cannot remove or demote the last owner of a tenant');
231
+ }
232
+ }
233
+ });
234
+ }
235
+ }