@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.
- package/README.md +444 -100
- package/dist/core/common/helpers/input.helper.js +11 -8
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +5 -7
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
- package/dist/core/common/services/email.service.d.ts +5 -1
- package/dist/core/common/services/email.service.js +16 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/request-context.service.js +6 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +16 -2
- package/dist/core/modules/tenant/core-tenant.guard.js +168 -22
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +3 -1
- package/dist/core/modules/tenant/core-tenant.service.js +14 -4
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +12 -1
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +32 -25
- package/src/core/common/helpers/input.helper.ts +24 -9
- package/src/core/common/interceptors/check-security.interceptor.ts +10 -11
- package/src/core/common/interfaces/server-options.interface.ts +45 -0
- package/src/core/common/services/email.service.ts +26 -5
- package/src/core/common/services/request-context.service.ts +8 -0
- package/src/core/modules/better-auth/README.md +20 -1
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
- package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +20 -1
- package/src/core/modules/tenant/README.md +71 -2
- package/src/core/modules/tenant/core-tenant.guard.ts +304 -27
- package/src/core/modules/tenant/core-tenant.service.ts +13 -4
- 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
|
-
|
|
198
|
+
### Membership Cache (since 11.21.1)
|
|
198
199
|
|
|
199
|
-
|
|
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
|
-
//
|
|
97
|
-
const
|
|
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 (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|