@lenne.tech/nest-server 11.21.0 → 11.21.2

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 (41) 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 +2 -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 +16 -2
  20. package/dist/core/modules/tenant/core-tenant.guard.js +168 -22
  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 +32 -25
  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 +45 -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/README.md +20 -1
  35. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
  36. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
  37. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +20 -1
  38. package/src/core/modules/tenant/README.md +71 -2
  39. package/src/core/modules/tenant/core-tenant.guard.ts +304 -27
  40. package/src/core/modules/tenant/core-tenant.service.ts +13 -4
  41. package/src/core/modules/user/core-user.service.ts +17 -1
@@ -43,6 +43,7 @@ multiTenancy: {
43
43
  membershipModel: 'TenantMember', // Mongoose model name (default)
44
44
  adminBypass: true, // System admins bypass membership (default: true)
45
45
  excludeSchemas: ['User', 'Session'], // Schemas without tenant filtering
46
+ cacheTtlMs: 30000, // Membership cache TTL in ms (default: 30s, 0 = disabled)
46
47
  roleHierarchy: { // Custom role hierarchy (default below)
47
48
  member: 1,
48
49
  manager: 2,
@@ -194,9 +195,45 @@ CoreTenantModule.forRoot({ service: TenantService });
194
195
 
195
196
  ## Performance Considerations
196
197
 
197
- The `CoreTenantGuard` resolves tenant memberships (`resolveUserTenantIds()`) on every authenticated request that does not include an `X-Tenant-Id` header. This is necessary so the Mongoose plugin can filter by `{ tenantId: { $in: tenantIds } }`.
198
+ ### Membership Cache (since 11.21.1)
198
199
 
199
- For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup.
200
+ The `CoreTenantGuard` uses an in-memory TTL cache for membership lookups and tenant ID resolution. This avoids repeated DB queries when the same user accesses the same tenant across multiple requests.
201
+
202
+ ```typescript
203
+ // config.env.ts — configure or disable the cache
204
+ multiTenancy: {
205
+ cacheTtlMs: 30000, // default: 30s. Set to 0 to disable.
206
+ }
207
+ ```
208
+
209
+ **Cache behavior:**
210
+
211
+ - **Positive-only:** Only active memberships are cached. Non-member lookups always hit the DB (security-first).
212
+ - **Auto-invalidation:** `CoreTenantService.addMember/removeMember/updateMemberRole` automatically invalidate the cache.
213
+ - **Config-change detection:** Cache is flushed when `multiTenancy` config changes (e.g., `roleHierarchy` update).
214
+ - **Bounded:** Max 500 entries with FIFO eviction. Memory overhead: ~100-250 KB.
215
+
216
+ **Important:** The cache is process-local. In horizontally scaled deployments (multiple instances), membership changes on one instance are not reflected on other instances until the TTL expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
217
+
218
+ ### Manual Cache Invalidation
219
+
220
+ When extending `CoreTenantService` with custom membership mutation methods, call `invalidateUser()` after changes:
221
+
222
+ ```typescript
223
+ @Injectable()
224
+ export class TenantService extends CoreTenantService {
225
+ async customMembershipChange(tenantId: string, userId: string) {
226
+ // ... your logic ...
227
+ this.tenantGuard?.invalidateUser(userId);
228
+ }
229
+ }
230
+ ```
231
+
232
+ Use `invalidateAll()` to flush the entire cache (e.g., after bulk operations).
233
+
234
+ ### SkipTenantCheck
235
+
236
+ For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
200
237
 
201
238
  ## Security Notes
202
239
 
@@ -225,8 +262,40 @@ export class TenantMemberController {
225
262
  }
226
263
  ```
227
264
 
265
+ ### BetterAuth (IAM) Integration
266
+
267
+ When both Multi-Tenancy and BetterAuth are active, IAM endpoints (sign-up, sign-in, sign-out,
268
+ session, etc.) automatically skip tenant validation by default. This is because authentication
269
+ typically happens before tenant context is established.
270
+
271
+ **Behavior with `skipTenantCheck: true` (default):**
272
+
273
+ - No `X-Tenant-Id` header → skip tenant validation, proceed without tenant context
274
+ - `X-Tenant-Id` header IS provided → validate normally (membership check, tenant context set)
275
+
276
+ The tenant is optional but respected when present. This allows tenant-aware auth flows
277
+ (subdomain-based, invite links, etc.) to coexist with the default cross-tenant behavior.
278
+
279
+ **Configuration:**
280
+
281
+ ```typescript
282
+ // config.env.ts — Default: skip tenant validation on IAM endpoints (most projects)
283
+ betterAuth: {
284
+ skipTenantCheck: true, // (default) No X-Tenant-Id header → skip; header present → validate
285
+ }
286
+
287
+ // config.env.ts — Tenant-aware auth (subdomain-based, invite links, SSO per tenant)
288
+ betterAuth: {
289
+ skipTenantCheck: false, // IAM endpoints ALWAYS require valid X-Tenant-Id header
290
+ }
291
+ ```
292
+
293
+ When `skipTenantCheck: false`, IAM endpoints will require a valid `X-Tenant-Id` header
294
+ and the user must be a member of that tenant for protected endpoints.
295
+
228
296
  ## Related
229
297
 
230
298
  - [Integration Checklist](./INTEGRATION-CHECKLIST.md)
231
299
  - [Configurable Features](../../../.claude/rules/configurable-features.md)
300
+
232
301
  - [Request Lifecycle](../../../docs/REQUEST-LIFECYCLE.md)
@@ -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
  *
@@ -36,11 +52,18 @@ import {
36
52
  * - Tenant context (header present): checks against membership.role only (user.roles ignored)
37
53
  * - No tenant context: checks against user.roles
38
54
  *
55
+ * BetterAuth (IAM) auto-skip behavior (skipTenantCheck config, default: true):
56
+ * - No X-Tenant-Id header: skip tenant validation entirely (auth before tenant is the expected case)
57
+ * - X-Tenant-Id header IS present: fall through to normal validation (membership check + context)
58
+ * This allows tenant-aware auth flows (subdomain-based, invite links, etc.) to coexist with
59
+ * the default cross-tenant behavior. The tenant is optional but respected when provided.
60
+ *
39
61
  * Flow:
40
62
  * 1. Config check: multiTenancy enabled?
41
63
  * 2. Parse header (X-Tenant-Id, max 128 chars, trimmed)
42
64
  * 3. @SkipTenantCheck → role check against user.roles, no tenant context
43
65
  * 4. Read @Roles() metadata, filter out system roles
66
+ * 5. BetterAuth auto-skip (betterAuth.skipTenantCheck config + no header) → skip, no tenant context
44
67
  *
45
68
  * HEADER PRESENT:
46
69
  * - System ADMIN (adminBypass: true) → set req.tenantId + isAdminBypass
@@ -58,13 +81,81 @@ import {
58
81
  * - No user + checkable roles → 403 "Authentication required"
59
82
  */
60
83
  @Injectable()
61
- export class CoreTenantGuard implements CanActivate {
84
+ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
62
85
  private readonly logger = new Logger(CoreTenantGuard.name);
63
86
 
87
+ /**
88
+ * In-memory TTL cache for membership lookups.
89
+ * Key: `${userId}:${tenantId}`, Value: cached membership result with expiry.
90
+ * Eliminates repeated DB queries for the same user+tenant combination.
91
+ */
92
+ private readonly membershipCache = new Map<string, CachedMembership>();
93
+
94
+ /**
95
+ * In-memory TTL cache for tenant ID resolution (no-header path).
96
+ * Key: `${userId}` or `${userId}:${minLevel}`, Value: cached tenant IDs with expiry.
97
+ */
98
+ private readonly tenantIdsCache = new Map<string, CachedTenantIds>();
99
+
100
+ /** Cache TTL in milliseconds. Configurable via multiTenancy.cacheTtlMs (default: 30s, 0 = disabled) */
101
+ private cacheTtlMs: number = 30_000;
102
+ /** Maximum cache entries before eviction */
103
+ private static readonly MAX_CACHE_SIZE = 500;
104
+ /** Cleanup interval handle */
105
+ private cleanupInterval: NodeJS.Timeout | null = null;
106
+ /** Tracks the last seen config reference to detect config changes (e.g., roleHierarchy) */
107
+ private lastSeenConfig: object | null = null;
108
+
64
109
  constructor(
65
110
  private readonly reflector: Reflector,
66
111
  @InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
67
- ) {}
112
+ ) {
113
+ // Clean up expired cache entries every 60 seconds
114
+ this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
115
+ if (this.cleanupInterval.unref) {
116
+ this.cleanupInterval.unref();
117
+ }
118
+ }
119
+
120
+ onModuleDestroy(): void {
121
+ if (this.cleanupInterval) {
122
+ clearInterval(this.cleanupInterval);
123
+ this.cleanupInterval = null;
124
+ }
125
+ this.membershipCache.clear();
126
+ this.tenantIdsCache.clear();
127
+ }
128
+
129
+ /**
130
+ * Invalidate all cache entries for a specific user.
131
+ * Call this when memberships change (add/remove/update).
132
+ *
133
+ * Note: userId must not contain ':' characters (used as cache key delimiter).
134
+ * MongoDB ObjectIds and standard UUID formats are safe.
135
+ *
136
+ * @param userId - The user ID whose cache entries should be invalidated
137
+ */
138
+ invalidateUser(userId: string): void {
139
+ for (const key of this.membershipCache.keys()) {
140
+ if (key.startsWith(`${userId}:`)) {
141
+ this.membershipCache.delete(key);
142
+ }
143
+ }
144
+ for (const key of this.tenantIdsCache.keys()) {
145
+ if (key === userId || key.startsWith(`${userId}:`)) {
146
+ this.tenantIdsCache.delete(key);
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Clear all cache entries.
153
+ * Useful when configuration changes (e.g., roleHierarchy) or for testing.
154
+ */
155
+ invalidateAll(): void {
156
+ this.membershipCache.clear();
157
+ this.tenantIdsCache.clear();
158
+ }
68
159
 
69
160
  async canActivate(context: ExecutionContext): Promise<boolean> {
70
161
  const config = ConfigService.configFastButReadOnly?.multiTenancy;
@@ -72,6 +163,16 @@ export class CoreTenantGuard implements CanActivate {
72
163
  return true;
73
164
  }
74
165
 
166
+ // Detect config changes (e.g., roleHierarchy modified in tests) and flush caches
167
+ if (this.lastSeenConfig !== config) {
168
+ this.lastSeenConfig = config;
169
+ // Default 30s in production, 0 (disabled) in test environments to avoid stale data between test cases
170
+ const isTestEnv =
171
+ process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
172
+ this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
173
+ this.invalidateAll();
174
+ }
175
+
75
176
  const request = this.getRequest(context);
76
177
  if (!request) {
77
178
  return true;
@@ -86,25 +187,45 @@ export class CoreTenantGuard implements CanActivate {
86
187
  // Read @Roles() metadata and filter to non-system roles
87
188
  const rolesMetadata = this.reflector.getAll<string[][]>('roles', [context.getHandler(), context.getClass()]);
88
189
  const roles = mergeRolesMetadata(rolesMetadata);
89
- const checkableRoles = roles.filter((r) => !isSystemRole(r));
90
- const minRequiredLevel = getMinRequiredLevel(checkableRoles);
91
190
 
92
191
  const user = request.user;
93
192
  const adminBypass = config.adminBypass !== false;
94
193
  const isAdmin = adminBypass && user?.roles?.includes(RoleEnum.ADMIN);
95
194
 
96
- // @SkipTenantCheck no tenant context, but role check against user.roles
97
- const skipTenantCheck = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
195
+ // Filter to checkable (non-system) roles only when needed (avoids array allocation on fast paths)
196
+ const hasNonSystemRoles = roles.some((r) => !isSystemRole(r));
197
+ const checkableRoles = hasNonSystemRoles ? roles.filter((r) => !isSystemRole(r)) : [];
198
+ const minRequiredLevel = checkableRoles.length > 0 ? getMinRequiredLevel(checkableRoles) : undefined;
199
+
200
+ // @SkipTenantCheck decorator → no tenant context, but role check against user.roles
201
+ const hasSkipDecorator = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
98
202
  context.getHandler(),
99
203
  context.getClass(),
100
204
  ]);
101
- if (skipTenantCheck) {
102
- if (checkableRoles.length > 0 && user) {
103
- if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
104
- throw new ForbiddenException('Insufficient role');
105
- }
205
+ if (hasSkipDecorator) {
206
+ return this.skipWithUserRoleCheck(checkableRoles, user, isAdmin);
207
+ }
208
+
209
+ // Auto-skip tenant check for BetterAuth (IAM) handlers when configured,
210
+ // but ONLY when no X-Tenant-Id header is present.
211
+ // - No header: skip tenant validation (auth before tenant is the expected case for most projects)
212
+ // - Header present: fall through to normal validation (membership check, tenant context set)
213
+ // This allows tenant-aware auth scenarios to coexist with the default cross-tenant behavior.
214
+ // Default config: betterAuth.skipTenantCheck = true (note: distinct from @SkipTenantCheck decorator above).
215
+ if (!hasSkipDecorator && !headerTenantId && this.isBetterAuthHandler(context)) {
216
+ const betterAuthConfig = ConfigService.configFastButReadOnly?.betterAuth;
217
+ // Boolean shorthand: `betterAuth: true` → skip, `betterAuth: false` → no skip
218
+ const shouldSkip =
219
+ betterAuthConfig !== null && betterAuthConfig !== undefined && typeof betterAuthConfig === 'object'
220
+ ? betterAuthConfig.skipTenantCheck !== false // default: true
221
+ : betterAuthConfig !== false; // true/undefined → skip; false → no skip
222
+
223
+ if (shouldSkip) {
224
+ this.logger.debug(
225
+ `BetterAuth auto-skip: ${context.getClass().name}::${context.getHandler().name} — no X-Tenant-Id header, skipping tenant validation`,
226
+ );
227
+ return this.skipWithUserRoleCheck(checkableRoles, user, isAdmin);
106
228
  }
107
- return true;
108
229
  }
109
230
 
110
231
  // === HEADER PRESENT ===
@@ -115,7 +236,9 @@ export class CoreTenantGuard implements CanActivate {
115
236
  request.tenantId = headerTenantId;
116
237
  request.isAdminBypass = true;
117
238
  const requiredRole = checkableRoles.length > 0 ? checkableRoles.join(',') : 'none';
118
- this.logger.log(`Admin bypass: user ${user.id} accessing tenant ${headerTenantId} (required: ${requiredRole})`);
239
+ // Sanitize control characters to prevent log injection
240
+ const safeTenantId = headerTenantId.replace(/[\r\n\t]/g, '_');
241
+ this.logger.log(`Admin bypass: user ${user.id} accessing tenant ${safeTenantId} (required: ${requiredRole})`);
119
242
  return true;
120
243
  }
121
244
 
@@ -125,14 +248,7 @@ export class CoreTenantGuard implements CanActivate {
125
248
  }
126
249
 
127
250
  // 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();
251
+ const membership = await this.findMembershipCached(user.id, headerTenantId);
136
252
 
137
253
  if (!membership) {
138
254
  throw new ForbiddenException('Not a member of this tenant');
@@ -193,33 +309,59 @@ export class CoreTenantGuard implements CanActivate {
193
309
  * This allows the tenant plugin to filter by { tenantId: { $in: tenantIds } }
194
310
  * when no specific tenant header is set.
195
311
  *
312
+ * Uses a process-level TTL cache to avoid repeated DB queries for the same user.
313
+ *
196
314
  * @param minLevel - When set, only include memberships where role level >= minLevel
197
315
  */
198
316
  private async resolveUserTenantIds(request: any, minLevel?: number): Promise<void> {
199
- // Skip if already resolved (request-scoped caching)
317
+ // Skip if already resolved on this request
200
318
  if (request.tenantIds) {
201
319
  return;
202
320
  }
203
321
 
322
+ const userId = request.user.id;
323
+ const ttl = this.cacheTtlMs;
324
+
325
+ // When cache is enabled, check process-level cache
326
+ if (ttl > 0) {
327
+ const cacheKey = minLevel !== undefined ? `${userId}:${minLevel}` : userId;
328
+ const now = Date.now();
329
+ const cached = this.tenantIdsCache.get(cacheKey);
330
+ if (cached && now < cached.expiresAt) {
331
+ request.tenantIds = cached.ids;
332
+ return;
333
+ }
334
+ }
335
+
204
336
  const memberships = await this.memberModel
205
337
  .find({
206
338
  status: TenantMemberStatus.ACTIVE,
207
- user: request.user.id,
339
+ user: userId,
208
340
  })
209
341
  .select('tenant role')
210
342
  .lean()
211
343
  .exec();
212
344
 
345
+ let ids: string[];
213
346
  if (minLevel !== undefined) {
214
347
  const hierarchy = getRoleHierarchy();
215
- request.tenantIds = memberships
348
+ ids = memberships
216
349
  .filter((m) => {
217
350
  const level = hierarchy[m.role as string] ?? 0;
218
351
  return level >= minLevel;
219
352
  })
220
- .map((m) => m.tenant);
353
+ .map((m) => m.tenant as string);
221
354
  } else {
222
- request.tenantIds = memberships.map((m) => m.tenant);
355
+ ids = memberships.map((m) => m.tenant as string);
356
+ }
357
+
358
+ request.tenantIds = ids;
359
+
360
+ // Store in process-level cache when enabled
361
+ if (ttl > 0) {
362
+ const cacheKey = minLevel !== undefined ? `${userId}:${minLevel}` : userId;
363
+ this.evictIfOverCapacity(this.tenantIdsCache);
364
+ this.tenantIdsCache.set(cacheKey, { expiresAt: Date.now() + ttl, ids });
223
365
  }
224
366
  }
225
367
 
@@ -237,4 +379,139 @@ export class CoreTenantGuard implements CanActivate {
237
379
  return null;
238
380
  }
239
381
  }
382
+
383
+ // ===================================================================================================================
384
+ // Cache helpers
385
+ // ===================================================================================================================
386
+
387
+ /**
388
+ * Look up a membership with process-level TTL cache.
389
+ * Avoids repeated DB queries when the same user accesses the same tenant repeatedly.
390
+ */
391
+ private async findMembershipCached(userId: string, tenantId: string): Promise<CoreTenantMemberModel | null> {
392
+ const ttl = this.cacheTtlMs;
393
+
394
+ // Cache disabled (ttl = 0): always query DB
395
+ if (ttl <= 0) {
396
+ return this.memberModel
397
+ .findOne({ status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId })
398
+ .lean()
399
+ .exec() as Promise<CoreTenantMemberModel | null>;
400
+ }
401
+
402
+ const key = `${userId}:${tenantId}`;
403
+ const now = Date.now();
404
+
405
+ const cached = this.membershipCache.get(key);
406
+ if (cached && now < cached.expiresAt) {
407
+ return cached.result;
408
+ }
409
+
410
+ const result = (await this.memberModel
411
+ .findOne({
412
+ status: TenantMemberStatus.ACTIVE,
413
+ tenant: tenantId,
414
+ user: userId,
415
+ })
416
+ .lean()
417
+ .exec()) as CoreTenantMemberModel | null;
418
+
419
+ // Only cache positive results (active membership found).
420
+ // Negative results (null) are NOT cached to ensure that:
421
+ // - Newly added members are recognized immediately
422
+ // - Removed memberships lead to immediate denial
423
+ if (result) {
424
+ this.evictIfOverCapacity(this.membershipCache);
425
+ this.membershipCache.set(key, { expiresAt: now + ttl, result });
426
+ } else {
427
+ // Ensure stale positive cache entries are removed
428
+ this.membershipCache.delete(key);
429
+ }
430
+ return result;
431
+ }
432
+
433
+ /**
434
+ * Evict the oldest entry if the cache exceeds MAX_CACHE_SIZE.
435
+ * Uses a simple FIFO strategy (Map insertion order).
436
+ */
437
+ private evictIfOverCapacity<T>(cache: Map<string, T>): void {
438
+ if (cache.size >= CoreTenantGuard.MAX_CACHE_SIZE) {
439
+ // Delete first 10% to avoid evicting on every insert
440
+ const deleteCount = Math.max(1, Math.floor(CoreTenantGuard.MAX_CACHE_SIZE * 0.1));
441
+ let deleted = 0;
442
+ for (const key of cache.keys()) {
443
+ if (deleted >= deleteCount) break;
444
+ cache.delete(key);
445
+ deleted++;
446
+ }
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Remove all expired entries from both caches.
452
+ * Called periodically by the cleanup interval.
453
+ */
454
+ private evictExpired(): void {
455
+ const now = Date.now();
456
+ for (const [key, entry] of this.membershipCache.entries()) {
457
+ if (now >= entry.expiresAt) {
458
+ this.membershipCache.delete(key);
459
+ }
460
+ }
461
+ for (const [key, entry] of this.tenantIdsCache.entries()) {
462
+ if (now >= entry.expiresAt) {
463
+ this.tenantIdsCache.delete(key);
464
+ }
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Skip tenant validation but still check non-system roles against user.roles.
470
+ * Shared by @SkipTenantCheck decorator path and BetterAuth auto-skip path.
471
+ */
472
+ private skipWithUserRoleCheck(checkableRoles: string[], user: any, isAdmin: boolean): true {
473
+ if (checkableRoles.length > 0) {
474
+ // Defense-in-depth: reject unauthenticated access even if RolesGuard is absent
475
+ if (!user) {
476
+ throw new ForbiddenException('Authentication required');
477
+ }
478
+ if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
479
+ throw new ForbiddenException('Insufficient role');
480
+ }
481
+ }
482
+ return true;
483
+ }
484
+
485
+ /**
486
+ * Check if the current request is handled by a BetterAuth (IAM) handler
487
+ * (controller or resolver). Used for auto-skip tenant check on IAM endpoints.
488
+ *
489
+ * Uses require() instead of import to avoid a circular dependency:
490
+ * tenant module → better-auth module (which may depend on tenant module indirectly).
491
+ * The require() is lazy and resolved only when needed (Node.js caches the result).
492
+ */
493
+ private isBetterAuthHandler(context: ExecutionContext): boolean {
494
+ const handler = context.getClass();
495
+ try {
496
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
497
+ const { CoreBetterAuthController } =
498
+ require('../better-auth/core-better-auth.controller') as typeof import('../better-auth/core-better-auth.controller');
499
+ if (handler === CoreBetterAuthController || handler.prototype instanceof CoreBetterAuthController) {
500
+ return true;
501
+ }
502
+ } catch {
503
+ /* BetterAuth controller not available */
504
+ }
505
+ try {
506
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
507
+ const { CoreBetterAuthResolver } =
508
+ require('../better-auth/core-better-auth.resolver') as typeof import('../better-auth/core-better-auth.resolver');
509
+ if (handler === CoreBetterAuthResolver || handler.prototype instanceof CoreBetterAuthResolver) {
510
+ return true;
511
+ }
512
+ } catch {
513
+ /* BetterAuth resolver not available */
514
+ }
515
+ return false;
516
+ }
240
517
  }
@@ -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