@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.
Files changed (71) hide show
  1. package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
  2. package/dist/core/common/decorators/restricted.decorator.js +4 -1
  3. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  4. package/dist/core/common/helpers/db.helper.d.ts +1 -1
  5. package/dist/core/common/helpers/db.helper.js +10 -4
  6. package/dist/core/common/helpers/db.helper.js.map +1 -1
  7. package/dist/core/common/helpers/input.helper.d.ts +1 -1
  8. package/dist/core/common/helpers/input.helper.js +6 -2
  9. package/dist/core/common/helpers/input.helper.js.map +1 -1
  10. package/dist/core/common/interceptors/check-security.interceptor.js +13 -1
  11. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  12. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -1
  13. package/dist/core/common/middleware/request-context.middleware.js +10 -6
  14. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  15. package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
  16. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  17. package/dist/core/common/services/request-context.service.d.ts +3 -0
  18. package/dist/core/common/services/request-context.service.js.map +1 -1
  19. package/dist/core/modules/auth/guards/roles.guard.js +6 -10
  20. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  21. package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
  22. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  23. package/dist/core/modules/tenant/core-tenant-member.model.d.ts +11 -0
  24. package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
  25. package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
  26. package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
  27. package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
  28. package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
  29. package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
  30. package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
  31. package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
  32. package/dist/core/modules/tenant/core-tenant.guard.d.ts +13 -0
  33. package/dist/core/modules/tenant/core-tenant.guard.js +162 -0
  34. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
  35. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
  36. package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
  37. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
  38. package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
  39. package/dist/core/modules/tenant/core-tenant.module.js +58 -0
  40. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
  41. package/dist/core/modules/tenant/core-tenant.service.d.ts +17 -0
  42. package/dist/core/modules/tenant/core-tenant.service.js +160 -0
  43. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
  44. package/dist/core.module.js +11 -0
  45. package/dist/core.module.js.map +1 -1
  46. package/dist/index.d.ts +7 -0
  47. package/dist/index.js +7 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/tsconfig.build.tsbuildinfo +1 -1
  50. package/package.json +12 -10
  51. package/src/core/common/decorators/restricted.decorator.ts +12 -2
  52. package/src/core/common/helpers/db.helper.ts +13 -6
  53. package/src/core/common/helpers/input.helper.ts +6 -2
  54. package/src/core/common/interceptors/check-security.interceptor.ts +17 -2
  55. package/src/core/common/interfaces/server-options.interface.ts +63 -30
  56. package/src/core/common/middleware/request-context.middleware.ts +12 -5
  57. package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
  58. package/src/core/common/services/request-context.service.ts +7 -1
  59. package/src/core/modules/auth/guards/roles.guard.ts +10 -10
  60. package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
  61. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +165 -0
  62. package/src/core/modules/tenant/README.md +232 -0
  63. package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
  64. package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
  65. package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
  66. package/src/core/modules/tenant/core-tenant.guard.ts +240 -0
  67. package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
  68. package/src/core/modules/tenant/core-tenant.module.ts +102 -0
  69. package/src/core/modules/tenant/core-tenant.service.ts +235 -0
  70. package/src/core.module.ts +15 -0
  71. package/src/index.ts +12 -0
@@ -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
+ }
@@ -33,6 +33,7 @@ import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
33
33
  import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
34
34
  import { CorePermissionsModule } from './core/modules/permissions/core-permissions.module';
35
35
  import { CoreSystemSetupModule } from './core/modules/system-setup/core-system-setup.module';
36
+ import { CoreTenantModule } from './core/modules/tenant/core-tenant.module';
36
37
 
37
38
  /**
38
39
  * Core module (dynamic)
@@ -390,6 +391,20 @@ export class CoreModule implements NestModule {
390
391
  imports.push(CoreSystemSetupModule);
391
392
  }
392
393
 
394
+ // Add CoreTenantModule when multiTenancy is configured (presence implies enabled)
395
+ if (config.multiTenancy && config.multiTenancy.enabled !== false) {
396
+ // Auto-add TenantMember to excludeSchemas (membership is tenant-spanning)
397
+ const membershipModelName = config.multiTenancy.membershipModel ?? 'TenantMember';
398
+ if (!config.multiTenancy.excludeSchemas) {
399
+ config.multiTenancy.excludeSchemas = [];
400
+ }
401
+ if (!config.multiTenancy.excludeSchemas.includes(membershipModelName)) {
402
+ config.multiTenancy.excludeSchemas.push(membershipModelName);
403
+ }
404
+
405
+ imports.push(CoreTenantModule.forRoot({ modelName: membershipModelName }));
406
+ }
407
+
393
408
  // Set exports
394
409
  const exports: any[] = [ConfigService, EmailService, TemplateService, MailjetService];
395
410
  if (!process.env.VITEST && isGraphQlEnabled) {
package/src/index.ts CHANGED
@@ -202,6 +202,18 @@ export * from './core/modules/system-setup/core-system-setup.service';
202
202
 
203
203
  export * from './core/modules/tus';
204
204
 
205
+ // =====================================================================================================================
206
+ // Core - Modules - Tenant
207
+ // =====================================================================================================================
208
+
209
+ export * from './core/modules/tenant/core-tenant-member.model';
210
+ export * from './core/modules/tenant/core-tenant.decorators';
211
+ export * from './core/modules/tenant/core-tenant.enums';
212
+ export * from './core/modules/tenant/core-tenant.guard';
213
+ export * from './core/modules/tenant/core-tenant.helpers';
214
+ export * from './core/modules/tenant/core-tenant.module';
215
+ export * from './core/modules/tenant/core-tenant.service';
216
+
205
217
  // =====================================================================================================================
206
218
  // Core - Modules - User
207
219
  // =====================================================================================================================