@lenne.tech/nest-server 11.21.0 → 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 (39) hide show
  1. package/README.md +444 -100
  2. package/dist/core/common/helpers/input.helper.js +11 -8
  3. package/dist/core/common/helpers/input.helper.js.map +1 -1
  4. package/dist/core/common/interceptors/check-security.interceptor.js +5 -7
  5. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  6. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  7. package/dist/core/common/services/email.service.d.ts +5 -1
  8. package/dist/core/common/services/email.service.js +16 -2
  9. package/dist/core/common/services/email.service.js.map +1 -1
  10. package/dist/core/common/services/request-context.service.js +6 -0
  11. package/dist/core/common/services/request-context.service.js.map +1 -1
  12. package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
  13. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
  14. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
  15. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
  17. package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
  18. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  19. package/dist/core/modules/tenant/core-tenant.guard.d.ts +14 -2
  20. package/dist/core/modules/tenant/core-tenant.guard.js +123 -14
  21. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  22. package/dist/core/modules/tenant/core-tenant.service.d.ts +3 -1
  23. package/dist/core/modules/tenant/core-tenant.service.js +14 -4
  24. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  25. package/dist/core/modules/user/core-user.service.js +12 -1
  26. package/dist/core/modules/user/core-user.service.js.map +1 -1
  27. package/dist/tsconfig.build.tsbuildinfo +1 -1
  28. package/package.json +30 -21
  29. package/src/core/common/helpers/input.helper.ts +24 -9
  30. package/src/core/common/interceptors/check-security.interceptor.ts +10 -11
  31. package/src/core/common/interfaces/server-options.interface.ts +19 -0
  32. package/src/core/common/services/email.service.ts +26 -5
  33. package/src/core/common/services/request-context.service.ts +8 -0
  34. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
  35. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
  36. package/src/core/modules/tenant/README.md +38 -2
  37. package/src/core/modules/tenant/core-tenant.guard.ts +219 -18
  38. package/src/core/modules/tenant/core-tenant.service.ts +13 -4
  39. package/src/core/modules/user/core-user.service.ts +17 -1
@@ -1,4 +1,4 @@
1
- import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger } from '@nestjs/common';
1
+ import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
2
2
  import { Reflector } from '@nestjs/core';
3
3
  import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
4
4
  import { InjectModel } from '@nestjs/mongoose';
@@ -17,6 +17,22 @@ import {
17
17
  mergeRolesMetadata,
18
18
  } from './core-tenant.helpers';
19
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
+
20
36
  /**
21
37
  * Global guard for multi-tenancy with defense-in-depth security.
22
38
  *
@@ -58,13 +74,81 @@ import {
58
74
  * - No user + checkable roles → 403 "Authentication required"
59
75
  */
60
76
  @Injectable()
61
- export class CoreTenantGuard implements CanActivate {
77
+ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
62
78
  private readonly logger = new Logger(CoreTenantGuard.name);
63
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
+
64
102
  constructor(
65
103
  private readonly reflector: Reflector,
66
104
  @InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
67
- ) {}
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
+ }
68
152
 
69
153
  async canActivate(context: ExecutionContext): Promise<boolean> {
70
154
  const config = ConfigService.configFastButReadOnly?.multiTenancy;
@@ -72,6 +156,16 @@ export class CoreTenantGuard implements CanActivate {
72
156
  return true;
73
157
  }
74
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
+
75
169
  const request = this.getRequest(context);
76
170
  if (!request) {
77
171
  return true;
@@ -86,13 +180,16 @@ export class CoreTenantGuard implements CanActivate {
86
180
  // Read @Roles() metadata and filter to non-system roles
87
181
  const rolesMetadata = this.reflector.getAll<string[][]>('roles', [context.getHandler(), context.getClass()]);
88
182
  const roles = mergeRolesMetadata(rolesMetadata);
89
- const checkableRoles = roles.filter((r) => !isSystemRole(r));
90
- const minRequiredLevel = getMinRequiredLevel(checkableRoles);
91
183
 
92
184
  const user = request.user;
93
185
  const adminBypass = config.adminBypass !== false;
94
186
  const isAdmin = adminBypass && user?.roles?.includes(RoleEnum.ADMIN);
95
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
+
96
193
  // @SkipTenantCheck → no tenant context, but role check against user.roles
97
194
  const skipTenantCheck = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
98
195
  context.getHandler(),
@@ -125,14 +222,7 @@ export class CoreTenantGuard implements CanActivate {
125
222
  }
126
223
 
127
224
  // 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();
225
+ const membership = await this.findMembershipCached(user.id, headerTenantId);
136
226
 
137
227
  if (!membership) {
138
228
  throw new ForbiddenException('Not a member of this tenant');
@@ -193,33 +283,59 @@ export class CoreTenantGuard implements CanActivate {
193
283
  * This allows the tenant plugin to filter by { tenantId: { $in: tenantIds } }
194
284
  * when no specific tenant header is set.
195
285
  *
286
+ * Uses a process-level TTL cache to avoid repeated DB queries for the same user.
287
+ *
196
288
  * @param minLevel - When set, only include memberships where role level >= minLevel
197
289
  */
198
290
  private async resolveUserTenantIds(request: any, minLevel?: number): Promise<void> {
199
- // Skip if already resolved (request-scoped caching)
291
+ // Skip if already resolved on this request
200
292
  if (request.tenantIds) {
201
293
  return;
202
294
  }
203
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
+
204
310
  const memberships = await this.memberModel
205
311
  .find({
206
312
  status: TenantMemberStatus.ACTIVE,
207
- user: request.user.id,
313
+ user: userId,
208
314
  })
209
315
  .select('tenant role')
210
316
  .lean()
211
317
  .exec();
212
318
 
319
+ let ids: string[];
213
320
  if (minLevel !== undefined) {
214
321
  const hierarchy = getRoleHierarchy();
215
- request.tenantIds = memberships
322
+ ids = memberships
216
323
  .filter((m) => {
217
324
  const level = hierarchy[m.role as string] ?? 0;
218
325
  return level >= minLevel;
219
326
  })
220
- .map((m) => m.tenant);
327
+ .map((m) => m.tenant as string);
221
328
  } else {
222
- request.tenantIds = memberships.map((m) => m.tenant);
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 });
223
339
  }
224
340
  }
225
341
 
@@ -237,4 +353,89 @@ export class CoreTenantGuard implements CanActivate {
237
353
  return null;
238
354
  }
239
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
+ }
240
441
  }
@@ -1,4 +1,4 @@
1
- import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
1
+ import { BadRequestException, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common';
2
2
  import { InjectModel } from '@nestjs/mongoose';
3
3
  import { Model } from 'mongoose';
4
4
 
@@ -6,6 +6,7 @@ import { ConfigService } from '../../common/services/config.service';
6
6
  import { RequestContext } from '../../common/services/request-context.service';
7
7
  import { CoreTenantMemberModel } from './core-tenant-member.model';
8
8
  import { DEFAULT_ROLE_HIERARCHY, TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums';
9
+ import { CoreTenantGuard } from './core-tenant.guard';
9
10
 
10
11
  /**
11
12
  * Core service for tenant membership operations.
@@ -30,7 +31,10 @@ import { DEFAULT_ROLE_HIERARCHY, TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus }
30
31
  export class CoreTenantService {
31
32
  protected readonly logger = new Logger(CoreTenantService.name);
32
33
 
33
- constructor(@InjectModel(TENANT_MEMBER_MODEL_TOKEN) protected readonly memberModel: Model<CoreTenantMemberModel>) {}
34
+ constructor(
35
+ @InjectModel(TENANT_MEMBER_MODEL_TOKEN) protected readonly memberModel: Model<CoreTenantMemberModel>,
36
+ @Optional() protected readonly tenantGuard?: CoreTenantGuard,
37
+ ) {}
34
38
 
35
39
  /**
36
40
  * Get the configured role hierarchy.
@@ -106,7 +110,7 @@ export class CoreTenantService {
106
110
  }
107
111
  // Reactivate suspended/invited membership
108
112
  return RequestContext.runWithBypassTenantGuard(async () => {
109
- return this.memberModel
113
+ const result = (await this.memberModel
110
114
  .findOneAndUpdate(
111
115
  { tenant: tenantId, user: userId },
112
116
  {
@@ -118,7 +122,9 @@ export class CoreTenantService {
118
122
  { new: true },
119
123
  )
120
124
  .lean()
121
- .exec() as Promise<CoreTenantMemberModel>;
125
+ .exec()) as CoreTenantMemberModel;
126
+ this.tenantGuard?.invalidateUser(userId);
127
+ return result;
122
128
  });
123
129
  }
124
130
 
@@ -131,6 +137,7 @@ export class CoreTenantService {
131
137
  tenant: tenantId,
132
138
  user: userId,
133
139
  });
140
+ this.tenantGuard?.invalidateUser(userId);
134
141
  return doc.toObject() as CoreTenantMemberModel;
135
142
  });
136
143
  }
@@ -162,6 +169,7 @@ export class CoreTenantService {
162
169
  throw new NotFoundException('Membership not found');
163
170
  }
164
171
 
172
+ this.tenantGuard?.invalidateUser(userId);
165
173
  return result as CoreTenantMemberModel;
166
174
  });
167
175
  }
@@ -202,6 +210,7 @@ export class CoreTenantService {
202
210
  throw new NotFoundException('Active membership not found');
203
211
  }
204
212
 
213
+ this.tenantGuard?.invalidateUser(userId);
205
214
  return result as CoreTenantMemberModel;
206
215
  });
207
216
  }
@@ -253,7 +253,14 @@ export abstract class CoreUserService<
253
253
  // Update and return user
254
254
  return this.process(
255
255
  async () => {
256
- return await this.mainDbModel.findByIdAndUpdate(userId, { roles }).exec();
256
+ const user = await this.mainDbModel.findByIdAndUpdate(userId, { roles }).exec();
257
+
258
+ // Invalidate BetterAuth user cache so changed roles take effect immediately
259
+ if (this.options?.betterAuthUserMapper && (user as any)?.iamId) {
260
+ this.options.betterAuthUserMapper.invalidateUserCache((user as any).iamId);
261
+ }
262
+
263
+ return user;
257
264
  },
258
265
  { serviceOptions },
259
266
  );
@@ -313,6 +320,15 @@ export abstract class CoreUserService<
313
320
  }
314
321
  }
315
322
 
323
+ // Invalidate BetterAuth user cache when roles or verified status may have changed
324
+ if (this.options?.betterAuthUserMapper && (oldUser as any)?.iamId) {
325
+ const rolesChanged = 'roles' in (input as any);
326
+ const verifiedChanged = 'verified' in (input as any) || 'emailVerified' in (input as any);
327
+ if (rolesChanged || verifiedChanged) {
328
+ this.options.betterAuthUserMapper.invalidateUserCache((oldUser as any).iamId);
329
+ }
330
+ }
331
+
316
332
  return updatedUser;
317
333
  }
318
334