@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
2
|
+
|
|
1
3
|
import { ConfigService } from '../services/config.service';
|
|
2
4
|
import { RequestContext } from '../services/request-context.service';
|
|
3
5
|
|
|
@@ -15,18 +17,21 @@ import { RequestContext } from '../services/request-context.service';
|
|
|
15
17
|
* - New documents get tenantId set automatically from context
|
|
16
18
|
* - Aggregates get a $match stage prepended
|
|
17
19
|
*
|
|
20
|
+
* **Filter modes:**
|
|
21
|
+
* - X-Tenant-Id header set → `{ tenantId: headerValue }` (single tenant)
|
|
22
|
+
* - No header + authenticated user → `{ tenantId: { $in: userTenantIds } }` (all user's tenants)
|
|
23
|
+
* - No header + no user → no filter (public/system routes)
|
|
24
|
+
*
|
|
18
25
|
* **No filter applied when:**
|
|
19
26
|
* - No RequestContext (system operations, cron jobs, migrations)
|
|
20
27
|
* - `bypassTenantGuard` is active (via `RequestContext.runWithBypassTenantGuard()`)
|
|
21
28
|
* - Schema's model name is in `excludeSchemas` config
|
|
22
|
-
* - No user on request (public endpoints)
|
|
23
|
-
*
|
|
24
|
-
* **User without tenantId:**
|
|
25
|
-
* - Filters by `{ tenantId: null }` — sees only data without tenant assignment
|
|
26
|
-
* - Falsy values (undefined, null, empty string '') are all treated as "no tenant"
|
|
27
29
|
*/
|
|
28
30
|
export function mongooseTenantPlugin(schema) {
|
|
29
|
-
// Only activate on schemas with a tenantId path
|
|
31
|
+
// Only activate on schemas with a tenantId path.
|
|
32
|
+
// CoreTenantMemberModel uses 'tenant' (not 'tenantId') intentionally, so this check
|
|
33
|
+
// excludes it at registration time. Additionally, 'TenantMember' is auto-added to
|
|
34
|
+
// excludeSchemas in CoreModule as defense-in-depth (see shouldBypass()).
|
|
30
35
|
if (!schema.path('tenantId')) {
|
|
31
36
|
return;
|
|
32
37
|
}
|
|
@@ -54,22 +59,21 @@ export function mongooseTenantPlugin(schema) {
|
|
|
54
59
|
schema.pre(hookName, function () {
|
|
55
60
|
// Query hooks: `this` is a Mongoose Query — modelName is on `this.model`
|
|
56
61
|
const modelName = this.model?.modelName;
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
this.where(
|
|
62
|
+
const filter = resolveTenantFilter(modelName);
|
|
63
|
+
if (filter !== undefined) {
|
|
64
|
+
this.where(filter);
|
|
60
65
|
}
|
|
61
66
|
});
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
// === Save: set tenantId automatically on new documents ===
|
|
65
70
|
// Intentional asymmetry: writes only set tenantId when truthy (not null).
|
|
66
|
-
//
|
|
67
|
-
// in query hooks will still make visible to them on reads.
|
|
71
|
+
// Only uses single tenantId from header — tenantIds array is for reads only.
|
|
68
72
|
schema.pre('save', function () {
|
|
69
73
|
if (this.isNew && !this['tenantId']) {
|
|
70
74
|
// Document hooks: `this` is the document instance — modelName is on the constructor (the Model class)
|
|
71
75
|
const modelName = (this.constructor as any).modelName;
|
|
72
|
-
const tenantId =
|
|
76
|
+
const tenantId = resolveSingleTenantId(modelName);
|
|
73
77
|
if (tenantId) {
|
|
74
78
|
this['tenantId'] = tenantId;
|
|
75
79
|
}
|
|
@@ -80,7 +84,7 @@ export function mongooseTenantPlugin(schema) {
|
|
|
80
84
|
schema.pre('insertMany', function (docs: any[]) {
|
|
81
85
|
// Model-level hooks: `this` is the Model class itself — modelName is a direct property
|
|
82
86
|
const modelName = this.modelName;
|
|
83
|
-
const tenantId =
|
|
87
|
+
const tenantId = resolveSingleTenantId(modelName);
|
|
84
88
|
if (tenantId && Array.isArray(docs)) {
|
|
85
89
|
for (const doc of docs) {
|
|
86
90
|
if (!doc.tenantId) {
|
|
@@ -94,25 +98,27 @@ export function mongooseTenantPlugin(schema) {
|
|
|
94
98
|
schema.pre('bulkWrite', function (ops: any[]) {
|
|
95
99
|
// Model-level hooks: `this` is the Model class itself — modelName is a direct property
|
|
96
100
|
const modelName = this.modelName;
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
101
|
+
const filter = resolveTenantFilter(modelName);
|
|
102
|
+
if (filter === undefined) return;
|
|
103
|
+
|
|
104
|
+
const tenantId = resolveSingleTenantId(modelName);
|
|
99
105
|
|
|
100
106
|
for (const op of ops) {
|
|
101
107
|
if ('insertOne' in op) {
|
|
102
|
-
// Auto-set tenantId on insert (only
|
|
108
|
+
// Auto-set tenantId on insert (only single tenantId, consistent with save hook)
|
|
103
109
|
if (tenantId && !op.insertOne.document.tenantId) {
|
|
104
110
|
op.insertOne.document.tenantId = tenantId;
|
|
105
111
|
}
|
|
106
112
|
} else if ('updateOne' in op) {
|
|
107
|
-
op.updateOne.filter = { ...op.updateOne.filter,
|
|
113
|
+
op.updateOne.filter = { ...op.updateOne.filter, ...filter };
|
|
108
114
|
} else if ('updateMany' in op) {
|
|
109
|
-
op.updateMany.filter = { ...op.updateMany.filter,
|
|
115
|
+
op.updateMany.filter = { ...op.updateMany.filter, ...filter };
|
|
110
116
|
} else if ('replaceOne' in op) {
|
|
111
|
-
op.replaceOne.filter = { ...op.replaceOne.filter,
|
|
117
|
+
op.replaceOne.filter = { ...op.replaceOne.filter, ...filter };
|
|
112
118
|
} else if ('deleteOne' in op) {
|
|
113
|
-
op.deleteOne.filter = { ...op.deleteOne.filter,
|
|
119
|
+
op.deleteOne.filter = { ...op.deleteOne.filter, ...filter };
|
|
114
120
|
} else if ('deleteMany' in op) {
|
|
115
|
-
op.deleteMany.filter = { ...op.deleteMany.filter,
|
|
121
|
+
op.deleteMany.filter = { ...op.deleteMany.filter, ...filter };
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
});
|
|
@@ -121,45 +127,72 @@ export function mongooseTenantPlugin(schema) {
|
|
|
121
127
|
schema.pre('aggregate', function () {
|
|
122
128
|
// Aggregate hooks: `this` is the Aggregation pipeline — the model is on the internal `_model` property
|
|
123
129
|
const modelName = (this as any)._model?.modelName;
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
this.pipeline().unshift({ $match:
|
|
130
|
+
const filter = resolveTenantFilter(modelName);
|
|
131
|
+
if (filter !== undefined) {
|
|
132
|
+
this.pipeline().unshift({ $match: filter });
|
|
127
133
|
}
|
|
128
134
|
});
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
/**
|
|
132
|
-
*
|
|
138
|
+
* Check common bypass conditions.
|
|
133
139
|
*
|
|
134
|
-
* @returns
|
|
135
|
-
* - `undefined` → no filter should be applied
|
|
136
|
-
* - `string` → filter by this tenant ID
|
|
137
|
-
* - `null` → filter by `{ tenantId: null }` (user without tenant sees only unassigned data)
|
|
140
|
+
* @returns `true` if filtering should be skipped, `false` otherwise
|
|
138
141
|
*/
|
|
139
|
-
function
|
|
140
|
-
// Defense-in-depth: check config even if plugin is registered
|
|
142
|
+
function shouldBypass(modelName?: string): boolean {
|
|
141
143
|
const mtConfig = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
142
|
-
if (!mtConfig || mtConfig.enabled === false) return
|
|
144
|
+
if (!mtConfig || mtConfig.enabled === false) return true;
|
|
143
145
|
|
|
144
146
|
const context = RequestContext.get();
|
|
147
|
+
if (!context) return true;
|
|
148
|
+
if (context.bypassTenantGuard) return true;
|
|
149
|
+
if (modelName && mtConfig.excludeSchemas?.includes(modelName)) return true;
|
|
145
150
|
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
148
153
|
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Resolve tenant filter from RequestContext for read operations (queries, aggregates).
|
|
156
|
+
*
|
|
157
|
+
* Defense-in-depth: If a schema has tenantId but there is no valid tenant context,
|
|
158
|
+
* throws ForbiddenException instead of returning unfiltered data (Safety Net).
|
|
159
|
+
*
|
|
160
|
+
* @returns
|
|
161
|
+
* - `undefined` → no filter should be applied (bypass active or plugin disabled)
|
|
162
|
+
* - `{}` → empty filter (admin bypass without header — sees all data)
|
|
163
|
+
* - `{ tenantId: string }` → filter by single validated tenant
|
|
164
|
+
* - `{ tenantId: { $in: string[] } }` → filter by user's tenant memberships
|
|
165
|
+
* @throws ForbiddenException when tenantId-schema is accessed without valid tenant context
|
|
166
|
+
*/
|
|
167
|
+
function resolveTenantFilter(modelName?: string): Record<string, any> | undefined {
|
|
168
|
+
if (shouldBypass(modelName)) return undefined;
|
|
169
|
+
|
|
170
|
+
const context = RequestContext.get();
|
|
151
171
|
|
|
152
|
-
//
|
|
153
|
-
|
|
172
|
+
// Validated tenant ID (set by CoreTenantGuard) → filter by it
|
|
173
|
+
const tenantId = context?.tenantId;
|
|
174
|
+
if (tenantId) return { tenantId };
|
|
154
175
|
|
|
155
|
-
|
|
176
|
+
// User has resolved memberships → filter by their tenants
|
|
177
|
+
const tenantIds = context?.tenantIds;
|
|
178
|
+
if (tenantIds) return { tenantId: { $in: tenantIds } };
|
|
156
179
|
|
|
157
|
-
//
|
|
158
|
-
if (
|
|
180
|
+
// Admin bypass without header → no filter, sees all data
|
|
181
|
+
if (context?.isAdminBypass) return {};
|
|
159
182
|
|
|
160
|
-
//
|
|
161
|
-
|
|
183
|
+
// SAFETY NET: Schema has tenantId but no valid tenant context.
|
|
184
|
+
// Throw instead of returning unfiltered data to prevent data leaks.
|
|
185
|
+
throw new ForbiddenException(
|
|
186
|
+
'Tenant context required: this data is tenant-scoped but no valid tenant context was provided',
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve single tenant ID for write operations (save, insertMany).
|
|
192
|
+
* Only returns a value when a specific tenant header is set.
|
|
193
|
+
*/
|
|
194
|
+
function resolveSingleTenantId(modelName?: string): string | undefined {
|
|
195
|
+
if (shouldBypass(modelName)) return undefined;
|
|
162
196
|
|
|
163
|
-
|
|
164
|
-
return undefined;
|
|
197
|
+
return RequestContext.get()?.tenantId || undefined;
|
|
165
198
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type SMTPPool = require('nodemailer/lib/smtp-pool');
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
|
4
5
|
import nodemailer = require('nodemailer');
|
|
5
6
|
import { Attachment } from 'nodemailer/lib/mailer';
|
|
6
7
|
|
|
@@ -12,7 +13,14 @@ import { TemplateService } from './template.service';
|
|
|
12
13
|
* Email service
|
|
13
14
|
*/
|
|
14
15
|
@Injectable()
|
|
15
|
-
export class EmailService {
|
|
16
|
+
export class EmailService implements OnModuleDestroy {
|
|
17
|
+
/**
|
|
18
|
+
* Cached transporter to avoid creating new SMTP connections per email.
|
|
19
|
+
* Reused as long as the SMTP config hasn't changed.
|
|
20
|
+
*/
|
|
21
|
+
private cachedTransporter: nodemailer.Transporter | null = null;
|
|
22
|
+
private cachedSmtpConfig: string | null = null;
|
|
23
|
+
|
|
16
24
|
/**
|
|
17
25
|
* Inject services
|
|
18
26
|
*/
|
|
@@ -21,6 +29,14 @@ export class EmailService {
|
|
|
21
29
|
protected templateService: TemplateService,
|
|
22
30
|
) {}
|
|
23
31
|
|
|
32
|
+
onModuleDestroy(): void {
|
|
33
|
+
if (this.cachedTransporter) {
|
|
34
|
+
this.cachedTransporter.close();
|
|
35
|
+
this.cachedTransporter = null;
|
|
36
|
+
this.cachedSmtpConfig = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
/**
|
|
25
41
|
* Send a mail
|
|
26
42
|
*/
|
|
@@ -74,11 +90,16 @@ export class EmailService {
|
|
|
74
90
|
isNonEmptyString(html);
|
|
75
91
|
}
|
|
76
92
|
|
|
77
|
-
//
|
|
78
|
-
|
|
93
|
+
// Reuse transporter if SMTP config hasn't changed (avoids creating new connections per email)
|
|
94
|
+
// Use hash instead of raw JSON to avoid keeping credentials as a string in memory
|
|
95
|
+
const smtpKey = createHash('sha256').update(JSON.stringify(smtp)).digest('hex');
|
|
96
|
+
if (!this.cachedTransporter || this.cachedSmtpConfig !== smtpKey) {
|
|
97
|
+
this.cachedTransporter = nodemailer.createTransport(smtp);
|
|
98
|
+
this.cachedSmtpConfig = smtpKey;
|
|
99
|
+
}
|
|
79
100
|
|
|
80
101
|
// Send mail
|
|
81
|
-
return
|
|
102
|
+
return this.cachedTransporter.sendMail({
|
|
82
103
|
attachments,
|
|
83
104
|
from: `"${senderName}" <${senderEmail}>`,
|
|
84
105
|
html,
|
|
@@ -11,8 +11,14 @@ export interface IRequestContext {
|
|
|
11
11
|
bypassRoleGuard?: boolean;
|
|
12
12
|
/** When true, mongooseTenantPlugin skips tenant filtering */
|
|
13
13
|
bypassTenantGuard?: boolean;
|
|
14
|
-
/**
|
|
14
|
+
/** Validated tenant ID (set by CoreTenantGuard after membership validation, not raw header) */
|
|
15
15
|
tenantId?: string;
|
|
16
|
+
/** Tenant IDs from user's active tenant memberships (used when no specific header is set) */
|
|
17
|
+
tenantIds?: string[];
|
|
18
|
+
/** Tenant role of the current user in the active tenant */
|
|
19
|
+
tenantRole?: string;
|
|
20
|
+
/** When true, indicates admin bypass is active (admin without header sees all data) */
|
|
21
|
+
isAdminBypass?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
@@ -64,6 +70,10 @@ export class RequestContext {
|
|
|
64
70
|
*/
|
|
65
71
|
static runWithBypassRoleGuard<T>(fn: () => T): T {
|
|
66
72
|
const currentStore = this.storage.getStore();
|
|
73
|
+
// Skip context creation if already bypassed (avoids redundant object spread)
|
|
74
|
+
if (currentStore?.bypassRoleGuard) {
|
|
75
|
+
return fn();
|
|
76
|
+
}
|
|
67
77
|
const context: IRequestContext = {
|
|
68
78
|
...currentStore,
|
|
69
79
|
bypassRoleGuard: true,
|
|
@@ -97,6 +107,10 @@ export class RequestContext {
|
|
|
97
107
|
*/
|
|
98
108
|
static runWithBypassTenantGuard<T>(fn: () => T): T {
|
|
99
109
|
const currentStore = this.storage.getStore();
|
|
110
|
+
// Skip context creation if already bypassed (avoids redundant object spread)
|
|
111
|
+
if (currentStore?.bypassTenantGuard) {
|
|
112
|
+
return fn();
|
|
113
|
+
}
|
|
100
114
|
const context: IRequestContext = {
|
|
101
115
|
...currentStore,
|
|
102
116
|
bypassTenantGuard: true,
|
|
@@ -16,6 +16,7 @@ import { BetterAuthTokenService } from '../../better-auth/better-auth-token.serv
|
|
|
16
16
|
import { BetterAuthenticatedUser } from '../../better-auth/better-auth.types';
|
|
17
17
|
import { CoreBetterAuthService } from '../../better-auth/core-better-auth.service';
|
|
18
18
|
import { ErrorCode } from '../../error-code';
|
|
19
|
+
import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../../tenant/core-tenant.helpers';
|
|
19
20
|
import { AuthGuardStrategy } from '../auth-guard-strategy.enum';
|
|
20
21
|
import { ExpiredTokenException } from '../exceptions/expired-token.exception';
|
|
21
22
|
import { InvalidTokenException } from '../exceptions/invalid-token.exception';
|
|
@@ -134,11 +135,7 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
134
135
|
context.getHandler(),
|
|
135
136
|
context.getClass(),
|
|
136
137
|
]);
|
|
137
|
-
const roles
|
|
138
|
-
? reflectorRoles[1]
|
|
139
|
-
? [...reflectorRoles[0], ...reflectorRoles[1]]
|
|
140
|
-
: reflectorRoles[0]
|
|
141
|
-
: reflectorRoles[1];
|
|
138
|
+
const roles = mergeRolesMetadata(reflectorRoles);
|
|
142
139
|
|
|
143
140
|
// Check if locked - always deny
|
|
144
141
|
if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
|
|
@@ -294,11 +291,7 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
294
291
|
context.getHandler(),
|
|
295
292
|
context.getClass(),
|
|
296
293
|
]);
|
|
297
|
-
const roles
|
|
298
|
-
? reflectorRoles[1]
|
|
299
|
-
? [...reflectorRoles[0], ...reflectorRoles[1]]
|
|
300
|
-
: reflectorRoles[0]
|
|
301
|
-
: reflectorRoles[1];
|
|
294
|
+
const roles = mergeRolesMetadata(reflectorRoles);
|
|
302
295
|
|
|
303
296
|
// Check if locked
|
|
304
297
|
if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
|
|
@@ -317,6 +310,13 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
317
310
|
return user;
|
|
318
311
|
}
|
|
319
312
|
|
|
313
|
+
// When multiTenancy active: pass through ALL non-system roles to CoreTenantGuard.
|
|
314
|
+
// CoreTenantGuard handles hierarchy (level) and non-hierarchy (exact) checks
|
|
315
|
+
// against membership.role (tenant) or user.roles (no tenant).
|
|
316
|
+
if (user && isMultiTenancyActive() && roles.some((r) => !isSystemRole(r))) {
|
|
317
|
+
return user;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
320
|
// If user is missing throw token exception
|
|
321
321
|
if (!user) {
|
|
322
322
|
if (err) {
|
|
@@ -10,6 +10,7 @@ import { GqlExecutionContext } from '@nestjs/graphql';
|
|
|
10
10
|
|
|
11
11
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
12
12
|
import { ErrorCode } from '../error-code';
|
|
13
|
+
import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../tenant/core-tenant.helpers';
|
|
13
14
|
import { BetterAuthTokenService } from './better-auth-token.service';
|
|
14
15
|
import { BetterAuthenticatedUser } from './better-auth.types';
|
|
15
16
|
import { CoreBetterAuthModule } from './core-better-auth.module';
|
|
@@ -79,12 +80,7 @@ export class BetterAuthRolesGuard implements CanActivate {
|
|
|
79
80
|
const classRoles = Reflect.getMetadata('roles', context.getClass()) as string[] | undefined;
|
|
80
81
|
|
|
81
82
|
// Combine handler and class roles (handler takes precedence, like Reflector.getAll)
|
|
82
|
-
const
|
|
83
|
-
const roles: string[] = reflectorRoles[0]
|
|
84
|
-
? reflectorRoles[1]
|
|
85
|
-
? [...reflectorRoles[0], ...reflectorRoles[1]]
|
|
86
|
-
: reflectorRoles[0]
|
|
87
|
-
: reflectorRoles[1];
|
|
83
|
+
const roles = mergeRolesMetadata([handlerRoles, classRoles]);
|
|
88
84
|
|
|
89
85
|
// Check if locked - always deny
|
|
90
86
|
if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
|
|
@@ -120,6 +116,13 @@ export class BetterAuthRolesGuard implements CanActivate {
|
|
|
120
116
|
return true;
|
|
121
117
|
}
|
|
122
118
|
|
|
119
|
+
// When multiTenancy active: pass through ALL non-system roles to CoreTenantGuard.
|
|
120
|
+
// CoreTenantGuard handles hierarchy (level) and non-hierarchy (exact) checks
|
|
121
|
+
// against membership.role (tenant) or user.roles (no tenant).
|
|
122
|
+
if (isMultiTenancyActive() && roles.some((r) => !isSystemRole(r))) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
123
126
|
// Check S_SELF role - user is accessing their own data
|
|
124
127
|
if (roles.includes(RoleEnum.S_SELF)) {
|
|
125
128
|
// Get the target object's ID from params or args
|
|
@@ -97,10 +97,32 @@ export interface SyncedUserDocument {
|
|
|
97
97
|
* - IAM → Legacy: Copies password from `accounts` to `users.password`
|
|
98
98
|
* - Legacy → IAM: Creates account entry in `accounts` from `users.password`
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* Cached user data for mapSessionUser (lightweight: only the fields needed for MappedUser)
|
|
102
|
+
*/
|
|
103
|
+
interface CachedUserData {
|
|
104
|
+
expiresAt: number;
|
|
105
|
+
id: string;
|
|
106
|
+
roles: string[];
|
|
107
|
+
verified: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
@Injectable()
|
|
101
111
|
export class CoreBetterAuthUserMapper {
|
|
102
112
|
private readonly logger = new Logger(CoreBetterAuthUserMapper.name);
|
|
103
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Lightweight TTL cache for user DB lookups.
|
|
116
|
+
* Key: iamId (BetterAuth user ID), Value: minimal user data (id, roles, verified).
|
|
117
|
+
* Only caches ~100 bytes per entry (no full documents). Max 500 entries = ~50KB.
|
|
118
|
+
*/
|
|
119
|
+
private readonly userCache = new Map<string, CachedUserData>();
|
|
120
|
+
private static readonly USER_CACHE_MAX = 500;
|
|
121
|
+
|
|
122
|
+
/** Cache TTL: 15s in production, disabled in test environments */
|
|
123
|
+
private static readonly USER_CACHE_TTL_MS =
|
|
124
|
+
process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e' ? 0 : 15_000;
|
|
125
|
+
|
|
104
126
|
constructor(@Optional() @InjectConnection() private readonly connection?: Connection) {}
|
|
105
127
|
|
|
106
128
|
/**
|
|
@@ -135,6 +157,23 @@ export class CoreBetterAuthUserMapper {
|
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
try {
|
|
160
|
+
// Check lightweight cache first (only stores id, roles, verified — ~100 bytes per entry)
|
|
161
|
+
const ttl = CoreBetterAuthUserMapper.USER_CACHE_TTL_MS;
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
const cached = ttl > 0 ? this.userCache.get(sessionUser.id) : undefined;
|
|
164
|
+
if (cached && now < cached.expiresAt) {
|
|
165
|
+
return this.createMappedUser({
|
|
166
|
+
email: sessionUser.email,
|
|
167
|
+
emailVerified: sessionUser.emailVerified,
|
|
168
|
+
iamId: sessionUser.id,
|
|
169
|
+
id: cached.id,
|
|
170
|
+
image: sessionUser.image,
|
|
171
|
+
name: sessionUser.name,
|
|
172
|
+
roles: cached.roles,
|
|
173
|
+
verified: cached.verified,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
138
177
|
// Look up the user in our database by email OR iamId
|
|
139
178
|
// This ensures we find the user regardless of which system they signed up with
|
|
140
179
|
const userCollection = this.connection.collection('users');
|
|
@@ -148,6 +187,11 @@ export class CoreBetterAuthUserMapper {
|
|
|
148
187
|
// Use database verified status, fallback to Better-Auth emailVerified
|
|
149
188
|
const verified = dbUser.verified === true || sessionUser.emailVerified === true;
|
|
150
189
|
|
|
190
|
+
// Cache only the minimal data needed (not the full document)
|
|
191
|
+
if (CoreBetterAuthUserMapper.USER_CACHE_TTL_MS > 0) {
|
|
192
|
+
this.cacheUserData(sessionUser.id, { id: dbUser._id.toString(), roles, verified });
|
|
193
|
+
}
|
|
194
|
+
|
|
151
195
|
return this.createMappedUser({
|
|
152
196
|
email: sessionUser.email,
|
|
153
197
|
emailVerified: sessionUser.emailVerified,
|
|
@@ -190,32 +234,53 @@ export class CoreBetterAuthUserMapper {
|
|
|
190
234
|
return {
|
|
191
235
|
...userData,
|
|
192
236
|
_authenticatedViaBetterAuth: true,
|
|
193
|
-
|
|
194
|
-
|
|
237
|
+
// Bind to static method to avoid creating a new closure per request
|
|
238
|
+
hasRole: CoreBetterAuthUserMapper.createHasRole(roles, userData.verified === true),
|
|
239
|
+
roles,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
195
242
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Creates a hasRole function. Uses a shared factory to minimize per-request closure size.
|
|
245
|
+
* The returned function only captures `roles` (string[]) and `verified` (boolean) — no large objects.
|
|
246
|
+
*/
|
|
247
|
+
private static createHasRole(roles: string[], verified: boolean): (checkRoles: string | string[]) => boolean {
|
|
248
|
+
return (checkRoles: string | string[]): boolean => {
|
|
249
|
+
const rolesToCheck = Array.isArray(checkRoles) ? checkRoles : [checkRoles];
|
|
200
250
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
251
|
+
if (rolesToCheck.includes(RoleEnum.S_EVERYONE)) return true;
|
|
252
|
+
if (rolesToCheck.includes(RoleEnum.S_USER)) return true;
|
|
253
|
+
if (rolesToCheck.includes(RoleEnum.S_NO_ONE)) return false;
|
|
254
|
+
if (rolesToCheck.includes(RoleEnum.S_VERIFIED)) return verified;
|
|
204
255
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
256
|
+
return rolesToCheck.some((role) => roles.includes(role));
|
|
257
|
+
};
|
|
258
|
+
}
|
|
208
259
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Store minimal user data in the lightweight cache.
|
|
262
|
+
* Only stores id, roles, verified — no full documents or large objects.
|
|
263
|
+
*/
|
|
264
|
+
private cacheUserData(iamId: string, data: Omit<CachedUserData, 'expiresAt'>): void {
|
|
265
|
+
// Evict oldest if at capacity (simple FIFO)
|
|
266
|
+
if (this.userCache.size >= CoreBetterAuthUserMapper.USER_CACHE_MAX) {
|
|
267
|
+
const firstKey = this.userCache.keys().next().value;
|
|
268
|
+
if (firstKey) this.userCache.delete(firstKey);
|
|
269
|
+
}
|
|
270
|
+
this.userCache.set(iamId, {
|
|
271
|
+
...data,
|
|
272
|
+
expiresAt: Date.now() + CoreBetterAuthUserMapper.USER_CACHE_TTL_MS,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
213
275
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Invalidate cached user data. Call when user roles or verified status change.
|
|
278
|
+
* Called automatically from `CoreUserService.setRoles()` and `CoreUserService.update()`.
|
|
279
|
+
*
|
|
280
|
+
* @param iamId - The BetterAuth user ID (from session/account, not MongoDB _id)
|
|
281
|
+
*/
|
|
282
|
+
invalidateUserCache(iamId: string): void {
|
|
283
|
+
this.userCache.delete(iamId);
|
|
219
284
|
}
|
|
220
285
|
|
|
221
286
|
// ===================================================================================================================
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BadRequestException, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
1
|
+
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
|
|
2
2
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
3
|
import { Request } from 'express';
|
|
4
4
|
import { importJWK, jwtVerify } from 'jose';
|
|
@@ -61,7 +61,7 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
|
|
|
61
61
|
export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
|
|
62
62
|
|
|
63
63
|
@Injectable()
|
|
64
|
-
export class CoreBetterAuthService {
|
|
64
|
+
export class CoreBetterAuthService implements OnModuleInit {
|
|
65
65
|
private readonly logger = new Logger(CoreBetterAuthService.name);
|
|
66
66
|
private readonly config: IBetterAuth;
|
|
67
67
|
|
|
@@ -78,6 +78,31 @@ export class CoreBetterAuthService {
|
|
|
78
78
|
this.config = this.resolvedConfig || this.configService?.get<IBetterAuth>('betterAuth') || {};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Ensure performance indices exist on session and users collections.
|
|
83
|
+
* Indices are idempotent — calling createIndex on an existing index is a no-op.
|
|
84
|
+
*/
|
|
85
|
+
async onModuleInit(): Promise<void> {
|
|
86
|
+
if (!this.isEnabled() || !this.connection?.db) return;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const db = this.connection.db;
|
|
90
|
+
|
|
91
|
+
// Session collection: token lookup (getSessionByToken) and user+expiry lookup (getActiveSessionForUser)
|
|
92
|
+
await db.collection('session').createIndex({ token: 1 });
|
|
93
|
+
await db.collection('session').createIndex({ userId: 1, expiresAt: 1 });
|
|
94
|
+
|
|
95
|
+
// Users collection: iamId lookup (mapSessionUser uses $or with email and iamId)
|
|
96
|
+
// email is typically already indexed by Mongoose schema, but iamId may not be
|
|
97
|
+
await db.collection('users').createIndex({ iamId: 1 }, { sparse: true });
|
|
98
|
+
|
|
99
|
+
this.logger.debug('Performance indices ensured on session and users collections');
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// Non-fatal: indices improve performance but are not required for correctness
|
|
102
|
+
this.logger.warn(`Could not create performance indices: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
/**
|
|
82
107
|
* Checks if better-auth is enabled and initialized
|
|
83
108
|
* Returns true only if:
|