@lenne.tech/nest-server 11.20.0 → 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/helpers/db.helper.d.ts +1 -1
- package/dist/core/common/helpers/db.helper.js +10 -4
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -1
- package/dist/core/common/helpers/input.helper.js +6 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +13 -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/helpers/db.helper.ts +13 -6
- package/src/core/common/helpers/input.helper.ts +6 -2
- package/src/core/common/interceptors/check-security.interceptor.ts +17 -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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection token for the TenantMember Mongoose model.
|
|
3
|
+
* Use this constant instead of the string literal 'TenantMember' in @InjectModel() and getModelToken().
|
|
4
|
+
*/
|
|
5
|
+
export const TENANT_MEMBER_MODEL_TOKEN = 'TenantMember';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Membership status for tenant members.
|
|
9
|
+
*/
|
|
10
|
+
export enum TenantMemberStatus {
|
|
11
|
+
ACTIVE = 'ACTIVE',
|
|
12
|
+
/** Reserved for future invitation workflow (not yet used in core logic) */
|
|
13
|
+
INVITED = 'INVITED',
|
|
14
|
+
SUSPENDED = 'SUSPENDED',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default role hierarchy for tenant membership.
|
|
19
|
+
* Keys are role names (stored in membership documents), values are numeric levels.
|
|
20
|
+
* Higher value = more privileges. Multiple roles can share the same level.
|
|
21
|
+
*
|
|
22
|
+
* Can be customized via `multiTenancy.roleHierarchy` in config.
|
|
23
|
+
*
|
|
24
|
+
* Hierarchy roles use level comparison: a higher level includes all lower levels.
|
|
25
|
+
* Non-hierarchy roles (not in this config) use exact match only.
|
|
26
|
+
*
|
|
27
|
+
* @example Custom hierarchy:
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const roleHierarchy = { viewer: 1, editor: 2, manager: 2, owner: 3 };
|
|
30
|
+
* const HR = createHierarchyRoles(roleHierarchy);
|
|
31
|
+
* // HR.VIEWER = 'viewer', HR.EDITOR = 'editor', HR.MANAGER = 'manager', HR.OWNER = 'owner'
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_ROLE_HIERARCHY: Record<string, number> = {
|
|
35
|
+
member: 1,
|
|
36
|
+
manager: 2,
|
|
37
|
+
owner: 3,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate typed UPPER_CASE constants from a role hierarchy config.
|
|
42
|
+
* Provides type-safe role strings for use with @Roles() and @Restricted() decorators.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const hierarchy = { viewer: 1, editor: 2, manager: 2, owner: 3 };
|
|
47
|
+
* const HR = createHierarchyRoles(hierarchy);
|
|
48
|
+
* // HR.VIEWER = 'viewer', HR.EDITOR = 'editor', HR.MANAGER = 'manager', HR.OWNER = 'owner'
|
|
49
|
+
*
|
|
50
|
+
* @Roles(HR.EDITOR) // requires at least level 2
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @returns Object with UPPER_CASE keys mapped to the original lowercase role name strings.
|
|
54
|
+
* E.g. `{ viewer: 1, owner: 3 }` → `{ VIEWER: 'viewer', OWNER: 'owner' }`.
|
|
55
|
+
*/
|
|
56
|
+
export function createHierarchyRoles<T extends Record<string, number>>(
|
|
57
|
+
hierarchy: T,
|
|
58
|
+
): { [K in keyof T as Uppercase<string & K>]: string & K } {
|
|
59
|
+
const result = {} as any;
|
|
60
|
+
for (const key of Object.keys(hierarchy)) {
|
|
61
|
+
result[key.toUpperCase()] = key;
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Type-safe constants for the default role hierarchy.
|
|
68
|
+
* Convenience export for projects using the default { member: 1, manager: 2, owner: 3 } hierarchy.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* @Roles(DefaultHR.MEMBER) // any active member (level >= 1)
|
|
73
|
+
* @Roles(DefaultHR.MANAGER) // at least manager level (level >= 2)
|
|
74
|
+
* @Roles(DefaultHR.OWNER) // highest level only (level >= 3)
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export const DefaultHR = createHierarchyRoles(DEFAULT_ROLE_HIERARCHY);
|
|
@@ -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
|
+
}
|