@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.
- package/README.md +444 -100
- package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
- package/dist/core/common/decorators/restricted.decorator.js +4 -1
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- 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 +10 -8
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +5 -1
- package/dist/core/common/middleware/request-context.middleware.js +10 -6
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
- package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
- package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
- 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.d.ts +3 -0
- 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/guards/roles.guard.js +6 -10
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +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-member.model.d.ts +11 -0
- package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
- package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
- package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
- package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
- package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
- package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +25 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +271 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
- package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
- package/dist/core/modules/tenant/core-tenant.module.js +58 -0
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.service.d.ts +19 -0
- package/dist/core/modules/tenant/core-tenant.service.js +170 -0
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
- 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/core.module.js +11 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +35 -24
- package/src/core/common/decorators/restricted.decorator.ts +12 -2
- package/src/core/common/helpers/input.helper.ts +24 -9
- package/src/core/common/interceptors/check-security.interceptor.ts +19 -13
- package/src/core/common/interfaces/server-options.interface.ts +80 -28
- package/src/core/common/middleware/request-context.middleware.ts +12 -5
- package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
- package/src/core/common/services/email.service.ts +26 -5
- package/src/core/common/services/request-context.service.ts +15 -1
- package/src/core/modules/auth/guards/roles.guard.ts +10 -10
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
- 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 +165 -0
- package/src/core/modules/tenant/README.md +268 -0
- package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
- package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
- package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +441 -0
- package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
- package/src/core/modules/tenant/core-tenant.module.ts +102 -0
- package/src/core/modules/tenant/core-tenant.service.ts +244 -0
- package/src/core/modules/user/core-user.service.ts +17 -1
- package/src/core.module.ts +15 -0
- 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
|
+
}
|