@lenne.tech/nest-server 11.20.1 → 11.21.0
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/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/interceptors/check-security.interceptor.js +5 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -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/request-context.service.d.ts +3 -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/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/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 +13 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +162 -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 +17 -0
- package/dist/core/modules/tenant/core-tenant.service.js +160 -0
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
- 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 +12 -10
- package/src/core/common/decorators/restricted.decorator.ts +12 -2
- package/src/core/common/interceptors/check-security.interceptor.ts +9 -2
- package/src/core/common/interfaces/server-options.interface.ts +63 -30
- 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/request-context.service.ts +7 -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/tenant/INTEGRATION-CHECKLIST.md +165 -0
- package/src/core/modules/tenant/README.md +232 -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 +240 -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 +235 -0
- package/src/core.module.ts +15 -0
- package/src/index.ts +12 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger } 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
|
+
* Global guard for multi-tenancy with defense-in-depth security.
|
|
22
|
+
*
|
|
23
|
+
* Only registered as APP_GUARD when multiTenancy is active.
|
|
24
|
+
* Runs after auth guards to validate tenant membership and role access.
|
|
25
|
+
*
|
|
26
|
+
* This guard is responsible for ALL non-system role checks when multiTenancy is active.
|
|
27
|
+
* RolesGuard passes through non-system roles to this guard.
|
|
28
|
+
*
|
|
29
|
+
* Security model:
|
|
30
|
+
* - Guard level: Membership validation, admin bypass, role checks (hierarchy + normal)
|
|
31
|
+
* - Plugin level: Safety net — ForbiddenException when tenantId-schema accessed without context
|
|
32
|
+
*
|
|
33
|
+
* Role check semantics:
|
|
34
|
+
* - Hierarchy roles (in roleHierarchy config): level comparison — higher includes lower
|
|
35
|
+
* - Normal roles (not in roleHierarchy): exact match — no compensation by higher role
|
|
36
|
+
* - Tenant context (header present): checks against membership.role only (user.roles ignored)
|
|
37
|
+
* - No tenant context: checks against user.roles
|
|
38
|
+
*
|
|
39
|
+
* Flow:
|
|
40
|
+
* 1. Config check: multiTenancy enabled?
|
|
41
|
+
* 2. Parse header (X-Tenant-Id, max 128 chars, trimmed)
|
|
42
|
+
* 3. @SkipTenantCheck → role check against user.roles, no tenant context
|
|
43
|
+
* 4. Read @Roles() metadata, filter out system roles
|
|
44
|
+
*
|
|
45
|
+
* HEADER PRESENT:
|
|
46
|
+
* - System ADMIN (adminBypass: true) → set req.tenantId + isAdminBypass
|
|
47
|
+
* - No user → 403 "Authentication required for tenant access"
|
|
48
|
+
* - Authenticated non-admin user:
|
|
49
|
+
* - Active member → checkRoleAccess against membership.role → set req.tenantId + tenantRole
|
|
50
|
+
* - Not active member → ALWAYS 403
|
|
51
|
+
*
|
|
52
|
+
* NO HEADER:
|
|
53
|
+
* - System ADMIN → set isAdminBypass (sees all data)
|
|
54
|
+
* - Authenticated + checkable roles → checkRoleAccess against user.roles
|
|
55
|
+
* → resolveUserTenantIds with minLevel filter
|
|
56
|
+
* - Authenticated + no checkable roles → resolveUserTenantIds (all memberships)
|
|
57
|
+
* - No user + no checkable roles → pass (plugin safety net catches tenantId-schema access)
|
|
58
|
+
* - No user + checkable roles → 403 "Authentication required"
|
|
59
|
+
*/
|
|
60
|
+
@Injectable()
|
|
61
|
+
export class CoreTenantGuard implements CanActivate {
|
|
62
|
+
private readonly logger = new Logger(CoreTenantGuard.name);
|
|
63
|
+
|
|
64
|
+
constructor(
|
|
65
|
+
private readonly reflector: Reflector,
|
|
66
|
+
@InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
|
|
67
|
+
) {}
|
|
68
|
+
|
|
69
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
70
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
71
|
+
if (!config || config.enabled === false) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const request = this.getRequest(context);
|
|
76
|
+
if (!request) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Parse tenant header
|
|
81
|
+
const headerName = (config.headerName ?? 'x-tenant-id').toLowerCase();
|
|
82
|
+
const rawHeader = request.headers?.[headerName] as string | undefined;
|
|
83
|
+
const headerTenantId =
|
|
84
|
+
rawHeader && typeof rawHeader === 'string' && rawHeader.length <= 128 ? rawHeader.trim() : undefined;
|
|
85
|
+
|
|
86
|
+
// Read @Roles() metadata and filter to non-system roles
|
|
87
|
+
const rolesMetadata = this.reflector.getAll<string[][]>('roles', [context.getHandler(), context.getClass()]);
|
|
88
|
+
const roles = mergeRolesMetadata(rolesMetadata);
|
|
89
|
+
const checkableRoles = roles.filter((r) => !isSystemRole(r));
|
|
90
|
+
const minRequiredLevel = getMinRequiredLevel(checkableRoles);
|
|
91
|
+
|
|
92
|
+
const user = request.user;
|
|
93
|
+
const adminBypass = config.adminBypass !== false;
|
|
94
|
+
const isAdmin = adminBypass && user?.roles?.includes(RoleEnum.ADMIN);
|
|
95
|
+
|
|
96
|
+
// @SkipTenantCheck → no tenant context, but role check against user.roles
|
|
97
|
+
const skipTenantCheck = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
|
|
98
|
+
context.getHandler(),
|
|
99
|
+
context.getClass(),
|
|
100
|
+
]);
|
|
101
|
+
if (skipTenantCheck) {
|
|
102
|
+
if (checkableRoles.length > 0 && user) {
|
|
103
|
+
if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
104
|
+
throw new ForbiddenException('Insufficient role');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// === HEADER PRESENT ===
|
|
111
|
+
if (headerTenantId) {
|
|
112
|
+
// Admin bypass: set req.tenantId so plugin filters (read by RequestContextMiddleware
|
|
113
|
+
// lazy getter → context.tenantId, also consumed by @CurrentTenant() via RequestContext)
|
|
114
|
+
if (isAdmin) {
|
|
115
|
+
request.tenantId = headerTenantId;
|
|
116
|
+
request.isAdminBypass = true;
|
|
117
|
+
const requiredRole = checkableRoles.length > 0 ? checkableRoles.join(',') : 'none';
|
|
118
|
+
this.logger.log(`Admin bypass: user ${user.id} accessing tenant ${headerTenantId} (required: ${requiredRole})`);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// No user + header → 403 (tenant access requires authentication)
|
|
123
|
+
if (!user) {
|
|
124
|
+
throw new ForbiddenException('Authentication required for tenant access');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 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();
|
|
136
|
+
|
|
137
|
+
if (!membership) {
|
|
138
|
+
throw new ForbiddenException('Not a member of this tenant');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const memberRole = membership.role as string;
|
|
142
|
+
|
|
143
|
+
// Check role access if roles are required (hierarchy + normal, against membership.role)
|
|
144
|
+
if (checkableRoles.length > 0) {
|
|
145
|
+
if (!checkRoleAccess(checkableRoles, undefined, memberRole)) {
|
|
146
|
+
throw new ForbiddenException('Insufficient tenant role');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Set validated tenant context on request (consumed by RequestContextMiddleware
|
|
151
|
+
// lazy getter → context.tenantId / context.tenantRole, and by @CurrentTenant() via RequestContext)
|
|
152
|
+
request.tenantId = headerTenantId;
|
|
153
|
+
request.tenantRole = memberRole;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// === NO HEADER ===
|
|
158
|
+
|
|
159
|
+
// Admin without header → sees all data
|
|
160
|
+
if (isAdmin) {
|
|
161
|
+
request.isAdminBypass = true;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Checkable roles present
|
|
166
|
+
if (checkableRoles.length > 0) {
|
|
167
|
+
// No user + roles required → 403
|
|
168
|
+
if (!user) {
|
|
169
|
+
throw new ForbiddenException('Authentication required');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check role access against user.roles (hierarchy: level comparison, normal: exact match)
|
|
173
|
+
if (!checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
174
|
+
throw new ForbiddenException('Insufficient role');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Resolve tenant IDs filtered by minimum required hierarchy level
|
|
178
|
+
await this.resolveUserTenantIds(request, minRequiredLevel);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Authenticated user without header and no checkable roles: resolve their tenant memberships
|
|
183
|
+
// so the plugin can filter by { tenantId: { $in: tenantIds } }
|
|
184
|
+
if (user) {
|
|
185
|
+
await this.resolveUserTenantIds(request);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Look up all active tenant memberships for the user and store them on the request.
|
|
193
|
+
* This allows the tenant plugin to filter by { tenantId: { $in: tenantIds } }
|
|
194
|
+
* when no specific tenant header is set.
|
|
195
|
+
*
|
|
196
|
+
* @param minLevel - When set, only include memberships where role level >= minLevel
|
|
197
|
+
*/
|
|
198
|
+
private async resolveUserTenantIds(request: any, minLevel?: number): Promise<void> {
|
|
199
|
+
// Skip if already resolved (request-scoped caching)
|
|
200
|
+
if (request.tenantIds) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const memberships = await this.memberModel
|
|
205
|
+
.find({
|
|
206
|
+
status: TenantMemberStatus.ACTIVE,
|
|
207
|
+
user: request.user.id,
|
|
208
|
+
})
|
|
209
|
+
.select('tenant role')
|
|
210
|
+
.lean()
|
|
211
|
+
.exec();
|
|
212
|
+
|
|
213
|
+
if (minLevel !== undefined) {
|
|
214
|
+
const hierarchy = getRoleHierarchy();
|
|
215
|
+
request.tenantIds = memberships
|
|
216
|
+
.filter((m) => {
|
|
217
|
+
const level = hierarchy[m.role as string] ?? 0;
|
|
218
|
+
return level >= minLevel;
|
|
219
|
+
})
|
|
220
|
+
.map((m) => m.tenant);
|
|
221
|
+
} else {
|
|
222
|
+
request.tenantIds = memberships.map((m) => m.tenant);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract request from GraphQL or HTTP context
|
|
228
|
+
*/
|
|
229
|
+
private getRequest(context: ExecutionContext): any {
|
|
230
|
+
if (context.getType<GqlContextType>() === 'graphql') {
|
|
231
|
+
const ctx = GqlExecutionContext.create(context);
|
|
232
|
+
return ctx.getContext()?.req;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return context.switchToHttp().getRequest();
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Model } from 'mongoose';
|
|
4
|
+
|
|
5
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
6
|
+
import { RequestContext } from '../../common/services/request-context.service';
|
|
7
|
+
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
8
|
+
import { DEFAULT_ROLE_HIERARCHY, TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Core service for tenant membership operations.
|
|
12
|
+
*
|
|
13
|
+
* Projects should extend this service via the Module Inheritance Pattern
|
|
14
|
+
* to add custom logic (e.g., tenant creation, invitation flows).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* @Injectable()
|
|
19
|
+
* export class TenantService extends CoreTenantService {
|
|
20
|
+
* override async addMember(tenantId: string, userId: string, role?: string) {
|
|
21
|
+
* const member = await super.addMember(tenantId, userId, role);
|
|
22
|
+
* // Custom: send notification email
|
|
23
|
+
* await this.notificationService.sendInvite(userId, tenantId);
|
|
24
|
+
* return member;
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
@Injectable()
|
|
30
|
+
export class CoreTenantService {
|
|
31
|
+
protected readonly logger = new Logger(CoreTenantService.name);
|
|
32
|
+
|
|
33
|
+
constructor(@InjectModel(TENANT_MEMBER_MODEL_TOKEN) protected readonly memberModel: Model<CoreTenantMemberModel>) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the configured role hierarchy.
|
|
37
|
+
*/
|
|
38
|
+
protected getHierarchy(): Record<string, number> {
|
|
39
|
+
return ConfigService.configFastButReadOnly?.multiTenancy?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the default (lowest) role name from the hierarchy.
|
|
44
|
+
*/
|
|
45
|
+
protected getDefaultRole(): string {
|
|
46
|
+
const hierarchy = this.getHierarchy();
|
|
47
|
+
const entries = Object.entries(hierarchy);
|
|
48
|
+
if (entries.length === 0) return 'member';
|
|
49
|
+
return entries.reduce((a, b) => (a[1] <= b[1] ? a : b))[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the highest role name from the hierarchy.
|
|
54
|
+
*/
|
|
55
|
+
protected getHighestRole(): string {
|
|
56
|
+
const hierarchy = this.getHierarchy();
|
|
57
|
+
const entries = Object.entries(hierarchy);
|
|
58
|
+
if (entries.length === 0) return 'owner';
|
|
59
|
+
return entries.reduce((a, b) => (a[1] >= b[1] ? a : b))[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find all active tenant memberships for a user.
|
|
64
|
+
*/
|
|
65
|
+
async findMemberships(userId: string): Promise<CoreTenantMemberModel[]> {
|
|
66
|
+
return this.memberModel.find({ status: TenantMemberStatus.ACTIVE, user: userId }).lean().exec() as Promise<
|
|
67
|
+
CoreTenantMemberModel[]
|
|
68
|
+
>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get a single membership (any status).
|
|
73
|
+
*/
|
|
74
|
+
async getMembership(tenantId: string, userId: string): Promise<CoreTenantMemberModel | null> {
|
|
75
|
+
return this.memberModel
|
|
76
|
+
.findOne({ tenant: tenantId, user: userId })
|
|
77
|
+
.lean()
|
|
78
|
+
.exec() as Promise<CoreTenantMemberModel | null>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Add a member to a tenant.
|
|
83
|
+
* Uses bypassTenantGuard to avoid tenant filtering on the membership collection itself.
|
|
84
|
+
*
|
|
85
|
+
* @param role - Role name from the configured hierarchy. Defaults to the lowest role.
|
|
86
|
+
*/
|
|
87
|
+
async addMember(
|
|
88
|
+
tenantId: string,
|
|
89
|
+
userId: string,
|
|
90
|
+
role?: string,
|
|
91
|
+
invitedById?: string,
|
|
92
|
+
): Promise<CoreTenantMemberModel> {
|
|
93
|
+
if (!tenantId?.trim()) {
|
|
94
|
+
throw new BadRequestException('tenantId must not be empty');
|
|
95
|
+
}
|
|
96
|
+
if (!userId?.trim()) {
|
|
97
|
+
throw new BadRequestException('userId must not be empty');
|
|
98
|
+
}
|
|
99
|
+
const effectiveRole = role ?? this.getDefaultRole();
|
|
100
|
+
|
|
101
|
+
// Check for existing membership
|
|
102
|
+
const existing = await this.getMembership(tenantId, userId);
|
|
103
|
+
if (existing) {
|
|
104
|
+
if (existing.status === TenantMemberStatus.ACTIVE) {
|
|
105
|
+
throw new BadRequestException('User is already an active member of this tenant');
|
|
106
|
+
}
|
|
107
|
+
// Reactivate suspended/invited membership
|
|
108
|
+
return RequestContext.runWithBypassTenantGuard(async () => {
|
|
109
|
+
return this.memberModel
|
|
110
|
+
.findOneAndUpdate(
|
|
111
|
+
{ tenant: tenantId, user: userId },
|
|
112
|
+
{
|
|
113
|
+
invitedBy: invitedById,
|
|
114
|
+
joinedAt: new Date(),
|
|
115
|
+
role: effectiveRole,
|
|
116
|
+
status: TenantMemberStatus.ACTIVE,
|
|
117
|
+
},
|
|
118
|
+
{ new: true },
|
|
119
|
+
)
|
|
120
|
+
.lean()
|
|
121
|
+
.exec() as Promise<CoreTenantMemberModel>;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return RequestContext.runWithBypassTenantGuard(async () => {
|
|
126
|
+
const doc = await this.memberModel.create({
|
|
127
|
+
invitedBy: invitedById,
|
|
128
|
+
joinedAt: new Date(),
|
|
129
|
+
role: effectiveRole,
|
|
130
|
+
status: TenantMemberStatus.ACTIVE,
|
|
131
|
+
tenant: tenantId,
|
|
132
|
+
user: userId,
|
|
133
|
+
});
|
|
134
|
+
return doc.toObject() as CoreTenantMemberModel;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Remove a member from a tenant (sets status to SUSPENDED).
|
|
140
|
+
* Prevents removing the last owner (highest role).
|
|
141
|
+
*/
|
|
142
|
+
async removeMember(tenantId: string, userId: string): Promise<CoreTenantMemberModel> {
|
|
143
|
+
if (!tenantId?.trim()) {
|
|
144
|
+
throw new BadRequestException('tenantId must not be empty');
|
|
145
|
+
}
|
|
146
|
+
if (!userId?.trim()) {
|
|
147
|
+
throw new BadRequestException('userId must not be empty');
|
|
148
|
+
}
|
|
149
|
+
await this.assertNotLastOwner(tenantId, userId);
|
|
150
|
+
|
|
151
|
+
return RequestContext.runWithBypassTenantGuard(async () => {
|
|
152
|
+
const result = await this.memberModel
|
|
153
|
+
.findOneAndUpdate(
|
|
154
|
+
{ status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId },
|
|
155
|
+
{ status: TenantMemberStatus.SUSPENDED },
|
|
156
|
+
{ new: true },
|
|
157
|
+
)
|
|
158
|
+
.lean()
|
|
159
|
+
.exec();
|
|
160
|
+
|
|
161
|
+
if (!result) {
|
|
162
|
+
throw new NotFoundException('Membership not found');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return result as CoreTenantMemberModel;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Update a member's role within a tenant.
|
|
171
|
+
* Prevents demoting the last owner (highest role).
|
|
172
|
+
*/
|
|
173
|
+
async updateMemberRole(tenantId: string, userId: string, role: string): Promise<CoreTenantMemberModel> {
|
|
174
|
+
if (!tenantId?.trim()) {
|
|
175
|
+
throw new BadRequestException('tenantId must not be empty');
|
|
176
|
+
}
|
|
177
|
+
if (!userId?.trim()) {
|
|
178
|
+
throw new BadRequestException('userId must not be empty');
|
|
179
|
+
}
|
|
180
|
+
if (!role?.trim()) {
|
|
181
|
+
throw new BadRequestException('role must not be empty');
|
|
182
|
+
}
|
|
183
|
+
const highestRole = this.getHighestRole();
|
|
184
|
+
|
|
185
|
+
// If demoting from highest role, ensure it's not the last one
|
|
186
|
+
const existing = await this.getMembership(tenantId, userId);
|
|
187
|
+
if (existing?.role === highestRole && role !== highestRole) {
|
|
188
|
+
await this.assertNotLastOwner(tenantId, userId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return RequestContext.runWithBypassTenantGuard(async () => {
|
|
192
|
+
const result = await this.memberModel
|
|
193
|
+
.findOneAndUpdate(
|
|
194
|
+
{ status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId },
|
|
195
|
+
{ role },
|
|
196
|
+
{ new: true },
|
|
197
|
+
)
|
|
198
|
+
.lean()
|
|
199
|
+
.exec();
|
|
200
|
+
|
|
201
|
+
if (!result) {
|
|
202
|
+
throw new NotFoundException('Active membership not found');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result as CoreTenantMemberModel;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Ensure the given user is not the last owner (highest role) of the tenant.
|
|
211
|
+
* Throws BadRequestException if removing/demoting them would leave the tenant without an owner.
|
|
212
|
+
*
|
|
213
|
+
* Note: This uses a read-check-act pattern which has a theoretical TOCTOU race under
|
|
214
|
+
* concurrent requests. For production environments with high concurrency, consider using
|
|
215
|
+
* MongoDB transactions (requires replica set) in your extended service.
|
|
216
|
+
*/
|
|
217
|
+
async assertNotLastOwner(tenantId: string, userId: string): Promise<void> {
|
|
218
|
+
const highestRole = this.getHighestRole();
|
|
219
|
+
|
|
220
|
+
return RequestContext.runWithBypassTenantGuard(async () => {
|
|
221
|
+
const ownerCount = await this.memberModel.countDocuments({
|
|
222
|
+
role: highestRole,
|
|
223
|
+
status: TenantMemberStatus.ACTIVE,
|
|
224
|
+
tenant: tenantId,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (ownerCount <= 1) {
|
|
228
|
+
const membership = await this.getMembership(tenantId, userId);
|
|
229
|
+
if (membership?.role === highestRole) {
|
|
230
|
+
throw new BadRequestException('Cannot remove or demote the last owner of a tenant');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|