@lenne.tech/nest-server 11.20.1 → 11.21.1

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 (84) hide show
  1. package/README.md +444 -100
  2. package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
  3. package/dist/core/common/decorators/restricted.decorator.js +4 -1
  4. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  5. package/dist/core/common/helpers/input.helper.js +11 -8
  6. package/dist/core/common/helpers/input.helper.js.map +1 -1
  7. package/dist/core/common/interceptors/check-security.interceptor.js +10 -8
  8. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  9. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -1
  10. package/dist/core/common/middleware/request-context.middleware.js +10 -6
  11. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  12. package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
  13. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  14. package/dist/core/common/services/email.service.d.ts +5 -1
  15. package/dist/core/common/services/email.service.js +16 -2
  16. package/dist/core/common/services/email.service.js.map +1 -1
  17. package/dist/core/common/services/request-context.service.d.ts +3 -0
  18. package/dist/core/common/services/request-context.service.js +6 -0
  19. package/dist/core/common/services/request-context.service.js.map +1 -1
  20. package/dist/core/modules/auth/guards/roles.guard.js +6 -10
  21. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  22. package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
  23. package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
  24. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  25. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
  26. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
  27. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
  29. package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
  30. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  31. package/dist/core/modules/tenant/core-tenant-member.model.d.ts +11 -0
  32. package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
  33. package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
  34. package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
  35. package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
  36. package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
  37. package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
  38. package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
  39. package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
  40. package/dist/core/modules/tenant/core-tenant.guard.d.ts +25 -0
  41. package/dist/core/modules/tenant/core-tenant.guard.js +271 -0
  42. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
  43. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
  44. package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
  45. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
  46. package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
  47. package/dist/core/modules/tenant/core-tenant.module.js +58 -0
  48. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
  49. package/dist/core/modules/tenant/core-tenant.service.d.ts +19 -0
  50. package/dist/core/modules/tenant/core-tenant.service.js +170 -0
  51. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
  52. package/dist/core/modules/user/core-user.service.js +12 -1
  53. package/dist/core/modules/user/core-user.service.js.map +1 -1
  54. package/dist/core.module.js +11 -0
  55. package/dist/core.module.js.map +1 -1
  56. package/dist/index.d.ts +7 -0
  57. package/dist/index.js +7 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/tsconfig.build.tsbuildinfo +1 -1
  60. package/package.json +35 -24
  61. package/src/core/common/decorators/restricted.decorator.ts +12 -2
  62. package/src/core/common/helpers/input.helper.ts +24 -9
  63. package/src/core/common/interceptors/check-security.interceptor.ts +19 -13
  64. package/src/core/common/interfaces/server-options.interface.ts +80 -28
  65. package/src/core/common/middleware/request-context.middleware.ts +12 -5
  66. package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
  67. package/src/core/common/services/email.service.ts +26 -5
  68. package/src/core/common/services/request-context.service.ts +15 -1
  69. package/src/core/modules/auth/guards/roles.guard.ts +10 -10
  70. package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
  71. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
  72. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
  73. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +165 -0
  74. package/src/core/modules/tenant/README.md +268 -0
  75. package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
  76. package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
  77. package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
  78. package/src/core/modules/tenant/core-tenant.guard.ts +441 -0
  79. package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
  80. package/src/core/modules/tenant/core-tenant.module.ts +102 -0
  81. package/src/core/modules/tenant/core-tenant.service.ts +244 -0
  82. package/src/core/modules/user/core-user.service.ts +17 -1
  83. package/src/core.module.ts +15 -0
  84. package/src/index.ts +12 -0
@@ -0,0 +1,441 @@
1
+ import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger, OnModuleDestroy } 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
+ * Cached membership entry with TTL
22
+ */
23
+ interface CachedMembership {
24
+ expiresAt: number;
25
+ result: CoreTenantMemberModel | null;
26
+ }
27
+
28
+ /**
29
+ * Cached tenant IDs entry with TTL
30
+ */
31
+ interface CachedTenantIds {
32
+ expiresAt: number;
33
+ ids: string[];
34
+ }
35
+
36
+ /**
37
+ * Global guard for multi-tenancy with defense-in-depth security.
38
+ *
39
+ * Only registered as APP_GUARD when multiTenancy is active.
40
+ * Runs after auth guards to validate tenant membership and role access.
41
+ *
42
+ * This guard is responsible for ALL non-system role checks when multiTenancy is active.
43
+ * RolesGuard passes through non-system roles to this guard.
44
+ *
45
+ * Security model:
46
+ * - Guard level: Membership validation, admin bypass, role checks (hierarchy + normal)
47
+ * - Plugin level: Safety net — ForbiddenException when tenantId-schema accessed without context
48
+ *
49
+ * Role check semantics:
50
+ * - Hierarchy roles (in roleHierarchy config): level comparison — higher includes lower
51
+ * - Normal roles (not in roleHierarchy): exact match — no compensation by higher role
52
+ * - Tenant context (header present): checks against membership.role only (user.roles ignored)
53
+ * - No tenant context: checks against user.roles
54
+ *
55
+ * Flow:
56
+ * 1. Config check: multiTenancy enabled?
57
+ * 2. Parse header (X-Tenant-Id, max 128 chars, trimmed)
58
+ * 3. @SkipTenantCheck → role check against user.roles, no tenant context
59
+ * 4. Read @Roles() metadata, filter out system roles
60
+ *
61
+ * HEADER PRESENT:
62
+ * - System ADMIN (adminBypass: true) → set req.tenantId + isAdminBypass
63
+ * - No user → 403 "Authentication required for tenant access"
64
+ * - Authenticated non-admin user:
65
+ * - Active member → checkRoleAccess against membership.role → set req.tenantId + tenantRole
66
+ * - Not active member → ALWAYS 403
67
+ *
68
+ * NO HEADER:
69
+ * - System ADMIN → set isAdminBypass (sees all data)
70
+ * - Authenticated + checkable roles → checkRoleAccess against user.roles
71
+ * → resolveUserTenantIds with minLevel filter
72
+ * - Authenticated + no checkable roles → resolveUserTenantIds (all memberships)
73
+ * - No user + no checkable roles → pass (plugin safety net catches tenantId-schema access)
74
+ * - No user + checkable roles → 403 "Authentication required"
75
+ */
76
+ @Injectable()
77
+ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
78
+ private readonly logger = new Logger(CoreTenantGuard.name);
79
+
80
+ /**
81
+ * In-memory TTL cache for membership lookups.
82
+ * Key: `${userId}:${tenantId}`, Value: cached membership result with expiry.
83
+ * Eliminates repeated DB queries for the same user+tenant combination.
84
+ */
85
+ private readonly membershipCache = new Map<string, CachedMembership>();
86
+
87
+ /**
88
+ * In-memory TTL cache for tenant ID resolution (no-header path).
89
+ * Key: `${userId}` or `${userId}:${minLevel}`, Value: cached tenant IDs with expiry.
90
+ */
91
+ private readonly tenantIdsCache = new Map<string, CachedTenantIds>();
92
+
93
+ /** Cache TTL in milliseconds. Configurable via multiTenancy.cacheTtlMs (default: 30s, 0 = disabled) */
94
+ private cacheTtlMs: number = 30_000;
95
+ /** Maximum cache entries before eviction */
96
+ private static readonly MAX_CACHE_SIZE = 500;
97
+ /** Cleanup interval handle */
98
+ private cleanupInterval: NodeJS.Timeout | null = null;
99
+ /** Tracks the last seen config reference to detect config changes (e.g., roleHierarchy) */
100
+ private lastSeenConfig: object | null = null;
101
+
102
+ constructor(
103
+ private readonly reflector: Reflector,
104
+ @InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
105
+ ) {
106
+ // Clean up expired cache entries every 60 seconds
107
+ this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
108
+ if (this.cleanupInterval.unref) {
109
+ this.cleanupInterval.unref();
110
+ }
111
+ }
112
+
113
+ onModuleDestroy(): void {
114
+ if (this.cleanupInterval) {
115
+ clearInterval(this.cleanupInterval);
116
+ this.cleanupInterval = null;
117
+ }
118
+ this.membershipCache.clear();
119
+ this.tenantIdsCache.clear();
120
+ }
121
+
122
+ /**
123
+ * Invalidate all cache entries for a specific user.
124
+ * Call this when memberships change (add/remove/update).
125
+ *
126
+ * Note: userId must not contain ':' characters (used as cache key delimiter).
127
+ * MongoDB ObjectIds and standard UUID formats are safe.
128
+ *
129
+ * @param userId - The user ID whose cache entries should be invalidated
130
+ */
131
+ invalidateUser(userId: string): void {
132
+ for (const key of this.membershipCache.keys()) {
133
+ if (key.startsWith(`${userId}:`)) {
134
+ this.membershipCache.delete(key);
135
+ }
136
+ }
137
+ for (const key of this.tenantIdsCache.keys()) {
138
+ if (key === userId || key.startsWith(`${userId}:`)) {
139
+ this.tenantIdsCache.delete(key);
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Clear all cache entries.
146
+ * Useful when configuration changes (e.g., roleHierarchy) or for testing.
147
+ */
148
+ invalidateAll(): void {
149
+ this.membershipCache.clear();
150
+ this.tenantIdsCache.clear();
151
+ }
152
+
153
+ async canActivate(context: ExecutionContext): Promise<boolean> {
154
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
155
+ if (!config || config.enabled === false) {
156
+ return true;
157
+ }
158
+
159
+ // Detect config changes (e.g., roleHierarchy modified in tests) and flush caches
160
+ if (this.lastSeenConfig !== config) {
161
+ this.lastSeenConfig = config;
162
+ // Default 30s in production, 0 (disabled) in test environments to avoid stale data between test cases
163
+ const isTestEnv =
164
+ process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
165
+ this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
166
+ this.invalidateAll();
167
+ }
168
+
169
+ const request = this.getRequest(context);
170
+ if (!request) {
171
+ return true;
172
+ }
173
+
174
+ // Parse tenant header
175
+ const headerName = (config.headerName ?? 'x-tenant-id').toLowerCase();
176
+ const rawHeader = request.headers?.[headerName] as string | undefined;
177
+ const headerTenantId =
178
+ rawHeader && typeof rawHeader === 'string' && rawHeader.length <= 128 ? rawHeader.trim() : undefined;
179
+
180
+ // Read @Roles() metadata and filter to non-system roles
181
+ const rolesMetadata = this.reflector.getAll<string[][]>('roles', [context.getHandler(), context.getClass()]);
182
+ const roles = mergeRolesMetadata(rolesMetadata);
183
+
184
+ const user = request.user;
185
+ const adminBypass = config.adminBypass !== false;
186
+ const isAdmin = adminBypass && user?.roles?.includes(RoleEnum.ADMIN);
187
+
188
+ // Filter to checkable (non-system) roles only when needed (avoids array allocation on fast paths)
189
+ const hasNonSystemRoles = roles.some((r) => !isSystemRole(r));
190
+ const checkableRoles = hasNonSystemRoles ? roles.filter((r) => !isSystemRole(r)) : [];
191
+ const minRequiredLevel = checkableRoles.length > 0 ? getMinRequiredLevel(checkableRoles) : undefined;
192
+
193
+ // @SkipTenantCheck → no tenant context, but role check against user.roles
194
+ const skipTenantCheck = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
195
+ context.getHandler(),
196
+ context.getClass(),
197
+ ]);
198
+ if (skipTenantCheck) {
199
+ if (checkableRoles.length > 0 && user) {
200
+ if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
201
+ throw new ForbiddenException('Insufficient role');
202
+ }
203
+ }
204
+ return true;
205
+ }
206
+
207
+ // === HEADER PRESENT ===
208
+ if (headerTenantId) {
209
+ // Admin bypass: set req.tenantId so plugin filters (read by RequestContextMiddleware
210
+ // lazy getter → context.tenantId, also consumed by @CurrentTenant() via RequestContext)
211
+ if (isAdmin) {
212
+ request.tenantId = headerTenantId;
213
+ request.isAdminBypass = true;
214
+ const requiredRole = checkableRoles.length > 0 ? checkableRoles.join(',') : 'none';
215
+ this.logger.log(`Admin bypass: user ${user.id} accessing tenant ${headerTenantId} (required: ${requiredRole})`);
216
+ return true;
217
+ }
218
+
219
+ // No user + header → 403 (tenant access requires authentication)
220
+ if (!user) {
221
+ throw new ForbiddenException('Authentication required for tenant access');
222
+ }
223
+
224
+ // Authenticated non-admin user: MUST be active member
225
+ const membership = await this.findMembershipCached(user.id, headerTenantId);
226
+
227
+ if (!membership) {
228
+ throw new ForbiddenException('Not a member of this tenant');
229
+ }
230
+
231
+ const memberRole = membership.role as string;
232
+
233
+ // Check role access if roles are required (hierarchy + normal, against membership.role)
234
+ if (checkableRoles.length > 0) {
235
+ if (!checkRoleAccess(checkableRoles, undefined, memberRole)) {
236
+ throw new ForbiddenException('Insufficient tenant role');
237
+ }
238
+ }
239
+
240
+ // Set validated tenant context on request (consumed by RequestContextMiddleware
241
+ // lazy getter → context.tenantId / context.tenantRole, and by @CurrentTenant() via RequestContext)
242
+ request.tenantId = headerTenantId;
243
+ request.tenantRole = memberRole;
244
+ return true;
245
+ }
246
+
247
+ // === NO HEADER ===
248
+
249
+ // Admin without header → sees all data
250
+ if (isAdmin) {
251
+ request.isAdminBypass = true;
252
+ return true;
253
+ }
254
+
255
+ // Checkable roles present
256
+ if (checkableRoles.length > 0) {
257
+ // No user + roles required → 403
258
+ if (!user) {
259
+ throw new ForbiddenException('Authentication required');
260
+ }
261
+
262
+ // Check role access against user.roles (hierarchy: level comparison, normal: exact match)
263
+ if (!checkRoleAccess(checkableRoles, user.roles, undefined)) {
264
+ throw new ForbiddenException('Insufficient role');
265
+ }
266
+
267
+ // Resolve tenant IDs filtered by minimum required hierarchy level
268
+ await this.resolveUserTenantIds(request, minRequiredLevel);
269
+ return true;
270
+ }
271
+
272
+ // Authenticated user without header and no checkable roles: resolve their tenant memberships
273
+ // so the plugin can filter by { tenantId: { $in: tenantIds } }
274
+ if (user) {
275
+ await this.resolveUserTenantIds(request);
276
+ }
277
+
278
+ return true;
279
+ }
280
+
281
+ /**
282
+ * Look up all active tenant memberships for the user and store them on the request.
283
+ * This allows the tenant plugin to filter by { tenantId: { $in: tenantIds } }
284
+ * when no specific tenant header is set.
285
+ *
286
+ * Uses a process-level TTL cache to avoid repeated DB queries for the same user.
287
+ *
288
+ * @param minLevel - When set, only include memberships where role level >= minLevel
289
+ */
290
+ private async resolveUserTenantIds(request: any, minLevel?: number): Promise<void> {
291
+ // Skip if already resolved on this request
292
+ if (request.tenantIds) {
293
+ return;
294
+ }
295
+
296
+ const userId = request.user.id;
297
+ const ttl = this.cacheTtlMs;
298
+
299
+ // When cache is enabled, check process-level cache
300
+ if (ttl > 0) {
301
+ const cacheKey = minLevel !== undefined ? `${userId}:${minLevel}` : userId;
302
+ const now = Date.now();
303
+ const cached = this.tenantIdsCache.get(cacheKey);
304
+ if (cached && now < cached.expiresAt) {
305
+ request.tenantIds = cached.ids;
306
+ return;
307
+ }
308
+ }
309
+
310
+ const memberships = await this.memberModel
311
+ .find({
312
+ status: TenantMemberStatus.ACTIVE,
313
+ user: userId,
314
+ })
315
+ .select('tenant role')
316
+ .lean()
317
+ .exec();
318
+
319
+ let ids: string[];
320
+ if (minLevel !== undefined) {
321
+ const hierarchy = getRoleHierarchy();
322
+ ids = memberships
323
+ .filter((m) => {
324
+ const level = hierarchy[m.role as string] ?? 0;
325
+ return level >= minLevel;
326
+ })
327
+ .map((m) => m.tenant as string);
328
+ } else {
329
+ ids = memberships.map((m) => m.tenant as string);
330
+ }
331
+
332
+ request.tenantIds = ids;
333
+
334
+ // Store in process-level cache when enabled
335
+ if (ttl > 0) {
336
+ const cacheKey = minLevel !== undefined ? `${userId}:${minLevel}` : userId;
337
+ this.evictIfOverCapacity(this.tenantIdsCache);
338
+ this.tenantIdsCache.set(cacheKey, { expiresAt: Date.now() + ttl, ids });
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Extract request from GraphQL or HTTP context
344
+ */
345
+ private getRequest(context: ExecutionContext): any {
346
+ if (context.getType<GqlContextType>() === 'graphql') {
347
+ const ctx = GqlExecutionContext.create(context);
348
+ return ctx.getContext()?.req;
349
+ }
350
+ try {
351
+ return context.switchToHttp().getRequest();
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+
357
+ // ===================================================================================================================
358
+ // Cache helpers
359
+ // ===================================================================================================================
360
+
361
+ /**
362
+ * Look up a membership with process-level TTL cache.
363
+ * Avoids repeated DB queries when the same user accesses the same tenant repeatedly.
364
+ */
365
+ private async findMembershipCached(userId: string, tenantId: string): Promise<CoreTenantMemberModel | null> {
366
+ const ttl = this.cacheTtlMs;
367
+
368
+ // Cache disabled (ttl = 0): always query DB
369
+ if (ttl <= 0) {
370
+ return this.memberModel
371
+ .findOne({ status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId })
372
+ .lean()
373
+ .exec() as Promise<CoreTenantMemberModel | null>;
374
+ }
375
+
376
+ const key = `${userId}:${tenantId}`;
377
+ const now = Date.now();
378
+
379
+ const cached = this.membershipCache.get(key);
380
+ if (cached && now < cached.expiresAt) {
381
+ return cached.result;
382
+ }
383
+
384
+ const result = (await this.memberModel
385
+ .findOne({
386
+ status: TenantMemberStatus.ACTIVE,
387
+ tenant: tenantId,
388
+ user: userId,
389
+ })
390
+ .lean()
391
+ .exec()) as CoreTenantMemberModel | null;
392
+
393
+ // Only cache positive results (active membership found).
394
+ // Negative results (null) are NOT cached to ensure that:
395
+ // - Newly added members are recognized immediately
396
+ // - Removed memberships lead to immediate denial
397
+ if (result) {
398
+ this.evictIfOverCapacity(this.membershipCache);
399
+ this.membershipCache.set(key, { expiresAt: now + ttl, result });
400
+ } else {
401
+ // Ensure stale positive cache entries are removed
402
+ this.membershipCache.delete(key);
403
+ }
404
+ return result;
405
+ }
406
+
407
+ /**
408
+ * Evict the oldest entry if the cache exceeds MAX_CACHE_SIZE.
409
+ * Uses a simple FIFO strategy (Map insertion order).
410
+ */
411
+ private evictIfOverCapacity<T>(cache: Map<string, T>): void {
412
+ if (cache.size >= CoreTenantGuard.MAX_CACHE_SIZE) {
413
+ // Delete first 10% to avoid evicting on every insert
414
+ const deleteCount = Math.max(1, Math.floor(CoreTenantGuard.MAX_CACHE_SIZE * 0.1));
415
+ let deleted = 0;
416
+ for (const key of cache.keys()) {
417
+ if (deleted >= deleteCount) break;
418
+ cache.delete(key);
419
+ deleted++;
420
+ }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Remove all expired entries from both caches.
426
+ * Called periodically by the cleanup interval.
427
+ */
428
+ private evictExpired(): void {
429
+ const now = Date.now();
430
+ for (const [key, entry] of this.membershipCache.entries()) {
431
+ if (now >= entry.expiresAt) {
432
+ this.membershipCache.delete(key);
433
+ }
434
+ }
435
+ for (const [key, entry] of this.tenantIdsCache.entries()) {
436
+ if (now >= entry.expiresAt) {
437
+ this.tenantIdsCache.delete(key);
438
+ }
439
+ }
440
+ }
441
+ }
@@ -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
+ }