@lenne.tech/nest-server 11.21.1 → 11.21.3
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/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +25 -25
- 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.js +8 -4
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +3 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +102 -10
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
- package/dist/test/test.helper.d.ts +6 -2
- package/dist/test/test.helper.js +28 -6
- package/dist/test/test.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +6 -8
- package/src/core/common/interfaces/server-options.interface.ts +26 -0
- package/src/core/modules/better-auth/README.md +20 -1
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +29 -25
- package/src/core/modules/better-auth/core-better-auth.service.ts +13 -9
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +32 -2
- package/src/core/modules/tenant/README.md +59 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +223 -16
- package/src/core/modules/tenant/core-tenant.helpers.ts +6 -2
- package/src/test/README.md +47 -0
- package/src/test/test.helper.ts +55 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.21.
|
|
3
|
+
"version": "11.21.3",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"class-validator": "0.15.1",
|
|
98
98
|
"compression": "1.8.1",
|
|
99
99
|
"cookie-parser": "1.4.7",
|
|
100
|
-
"dotenv": "17.
|
|
100
|
+
"dotenv": "17.4.0",
|
|
101
101
|
"ejs": "5.0.1",
|
|
102
102
|
"express": "5.2.1",
|
|
103
103
|
"graphql": "16.13.2",
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"graphql-upload": "15.0.2",
|
|
107
107
|
"js-sha256": "0.11.1",
|
|
108
108
|
"json-to-graphql-query": "2.3.0",
|
|
109
|
-
"lodash": "4.
|
|
109
|
+
"lodash": "4.18.1",
|
|
110
110
|
"mongodb": "7.1.1",
|
|
111
111
|
"mongoose": "9.3.3",
|
|
112
112
|
"multer": "2.1.1",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"@nestjs/cli": "11.0.17",
|
|
126
126
|
"@nestjs/schematics": "11.0.10",
|
|
127
127
|
"@nestjs/testing": "11.1.17",
|
|
128
|
-
"@swc/cli": "0.8.
|
|
128
|
+
"@swc/cli": "0.8.1",
|
|
129
129
|
"@swc/core": "1.15.21",
|
|
130
130
|
"@types/compression": "1.8.1",
|
|
131
131
|
"@types/cookie-parser": "1.4.10",
|
|
@@ -182,10 +182,7 @@
|
|
|
182
182
|
"rollup@>=4.0.0 <4.60.1": "4.60.1",
|
|
183
183
|
"ajv@<6.14.0": "6.14.0",
|
|
184
184
|
"ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
|
|
185
|
-
"file-type@>=13.0.0 <21.3.2": "21.3.2",
|
|
186
185
|
"undici@>=7.0.0 <7.24.0": "7.24.3",
|
|
187
|
-
"yauzl@<3.2.1": "3.2.1",
|
|
188
|
-
"flatted@<=3.4.1": "3.4.2",
|
|
189
186
|
"srvx@<0.11.13": "0.11.13",
|
|
190
187
|
"handlebars@>=4.0.0 <4.7.9": "4.7.9",
|
|
191
188
|
"brace-expansion@<1.1.13": "1.1.13",
|
|
@@ -193,7 +190,8 @@
|
|
|
193
190
|
"picomatch@<2.3.2": "2.3.2",
|
|
194
191
|
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
|
195
192
|
"path-to-regexp@>=8.0.0 <8.4.0": "8.4.1",
|
|
196
|
-
"kysely@>=0.26.0 <0.28.15": "0.28.15"
|
|
193
|
+
"kysely@>=0.26.0 <0.28.15": "0.28.15",
|
|
194
|
+
"lodash@>=4.0.0 <4.18.0": "4.18.1"
|
|
197
195
|
},
|
|
198
196
|
"onlyBuiltDependencies": [
|
|
199
197
|
"bcrypt",
|
|
@@ -2401,6 +2401,32 @@ interface IBetterAuthBase {
|
|
|
2401
2401
|
*/
|
|
2402
2402
|
secret?: string;
|
|
2403
2403
|
|
|
2404
|
+
/**
|
|
2405
|
+
* Skip tenant validation on IAM endpoints.
|
|
2406
|
+
*
|
|
2407
|
+
* When true (default), IAM endpoints (sign-up, sign-in, sign-out, session, etc.)
|
|
2408
|
+
* skip the CoreTenantGuard tenant membership check. This is the correct default
|
|
2409
|
+
* because authentication typically happens BEFORE tenant context is established.
|
|
2410
|
+
*
|
|
2411
|
+
* Set to false for scenarios where tenant context is known at login time:
|
|
2412
|
+
* - Subdomain-based tenancy (tenant-a.crm.example.com)
|
|
2413
|
+
* - Invite links with embedded tenant (crm.example.com/invite/abc123)
|
|
2414
|
+
* - Tenant-specific login pages (crm.example.com/login?org=tenant-a)
|
|
2415
|
+
* - Tenant-specific auth policies (e.g., one tenant requires SSO)
|
|
2416
|
+
*
|
|
2417
|
+
* @default true
|
|
2418
|
+
*
|
|
2419
|
+
* @example
|
|
2420
|
+
* ```typescript
|
|
2421
|
+
* // Default: IAM endpoints skip tenant validation (correct for most cases)
|
|
2422
|
+
* betterAuth: { skipTenantCheck: true }
|
|
2423
|
+
*
|
|
2424
|
+
* // Tenant-aware authentication (subdomain-based tenancy, invite links, etc.)
|
|
2425
|
+
* betterAuth: { skipTenantCheck: false }
|
|
2426
|
+
* ```
|
|
2427
|
+
*/
|
|
2428
|
+
skipTenantCheck?: boolean;
|
|
2429
|
+
|
|
2404
2430
|
/**
|
|
2405
2431
|
* Sign-up checks configuration.
|
|
2406
2432
|
*
|
|
@@ -2137,4 +2137,23 @@ For frontend integration with Better-Auth, see the **[Integration Checklist](./I
|
|
|
2137
2137
|
1. **Password Hashing**: Always hash passwords with SHA256 client-side before sending
|
|
2138
2138
|
2. **2FA Redirect**: Check for `twoFactorRedirect: true` in sign-in response
|
|
2139
2139
|
3. **Passkey Session**: Passkey auth returns session without user - call `validateSession()` to fetch user data
|
|
2140
|
-
4. **Credentials**: Use `credentials: 'include'` for cross-origin cookie handling
|
|
2140
|
+
4. **Credentials**: Use `credentials: 'include'` for cross-origin cookie handling
|
|
2141
|
+
|
|
2142
|
+
---
|
|
2143
|
+
|
|
2144
|
+
## Multi-Tenancy Integration
|
|
2145
|
+
|
|
2146
|
+
When both BetterAuth and Multi-Tenancy (`multiTenancy: {}`) are active, IAM endpoints
|
|
2147
|
+
automatically skip tenant validation by default (`skipTenantCheck: true`). This is the
|
|
2148
|
+
correct default — authentication happens before tenant context is established.
|
|
2149
|
+
|
|
2150
|
+
For tenant-aware authentication scenarios (subdomain-based tenancy, invite links, etc.),
|
|
2151
|
+
set `skipTenantCheck: false`:
|
|
2152
|
+
|
|
2153
|
+
```typescript
|
|
2154
|
+
betterAuth: {
|
|
2155
|
+
skipTenantCheck: false, // IAM endpoints require valid X-Tenant-Id header
|
|
2156
|
+
}
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
See the [CoreTenantModule README](../tenant/README.md#betterauth-iam-integration) for details.
|
|
@@ -1019,32 +1019,36 @@ export class CoreBetterAuthUserMapper {
|
|
|
1019
1019
|
const migrationPercentage = totalUsers > 0 ? Math.round((fullyMigratedUsers / totalUsers) * 100 * 100) / 100 : 0;
|
|
1020
1020
|
|
|
1021
1021
|
// Get emails of pending users (limit to 100)
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
from: 'account',
|
|
1029
|
-
localField: '_id',
|
|
1030
|
-
},
|
|
1031
|
-
},
|
|
1032
|
-
{
|
|
1033
|
-
$match: {
|
|
1034
|
-
$or: [
|
|
1035
|
-
{ iamId: { $exists: false } },
|
|
1036
|
-
{ iamId: null },
|
|
1037
|
-
{
|
|
1038
|
-
$and: [{ iamId: { $exists: true, $ne: null } }, { 'accounts.providerId': { $ne: 'credential' } }],
|
|
1039
|
-
},
|
|
1040
|
-
],
|
|
1041
|
-
},
|
|
1042
|
-
},
|
|
1043
|
-
{ $limit: 100 },
|
|
1044
|
-
{ $project: { email: 1 } },
|
|
1045
|
-
])
|
|
1022
|
+
// Two-phase approach: first get users without iamId (no $lookup needed),
|
|
1023
|
+
// then check users with iamId but missing credential account
|
|
1024
|
+
const usersWithoutIamId = await usersCollection
|
|
1025
|
+
.find({ $or: [{ iamId: { $exists: false } }, { iamId: null }] })
|
|
1026
|
+
.limit(100)
|
|
1027
|
+
.project({ email: 1 })
|
|
1046
1028
|
.toArray();
|
|
1047
|
-
|
|
1029
|
+
|
|
1030
|
+
const remaining = 100 - usersWithoutIamId.length;
|
|
1031
|
+
let usersWithIamButNoAccount: { email?: string }[] = [];
|
|
1032
|
+
if (remaining > 0) {
|
|
1033
|
+
usersWithIamButNoAccount = await usersCollection
|
|
1034
|
+
.aggregate([
|
|
1035
|
+
{ $match: { iamId: { $exists: true, $ne: null } } },
|
|
1036
|
+
{
|
|
1037
|
+
$lookup: {
|
|
1038
|
+
as: 'accounts',
|
|
1039
|
+
foreignField: 'userId',
|
|
1040
|
+
from: 'account',
|
|
1041
|
+
localField: '_id',
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
{ $match: { 'accounts.providerId': { $ne: 'credential' } } },
|
|
1045
|
+
{ $limit: remaining },
|
|
1046
|
+
{ $project: { email: 1 } },
|
|
1047
|
+
])
|
|
1048
|
+
.toArray();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const pendingUserEmails = [...usersWithoutIamId, ...usersWithIamButNoAccount].map((u) => u.email).filter(Boolean);
|
|
1048
1052
|
|
|
1049
1053
|
// Can disable legacy auth only if ALL users are fully migrated
|
|
1050
1054
|
const canDisableLegacyAuth = totalUsers > 0 && fullyMigratedUsers === totalUsers;
|
|
@@ -88,15 +88,19 @@ export class CoreBetterAuthService implements OnModuleInit {
|
|
|
88
88
|
try {
|
|
89
89
|
const db = this.connection.db;
|
|
90
90
|
|
|
91
|
-
//
|
|
92
|
-
await
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
// All indices are idempotent (no-op if already exists) and independent — run in parallel
|
|
92
|
+
await Promise.all([
|
|
93
|
+
// Session: token lookup (getSessionByToken) and user+expiry lookup (getActiveSessionForUser)
|
|
94
|
+
db.collection('session').createIndex({ token: 1 }),
|
|
95
|
+
db.collection('session').createIndex({ userId: 1, expiresAt: 1 }),
|
|
96
|
+
// Users: iamId lookup (mapSessionUser uses $or with email and iamId)
|
|
97
|
+
db.collection('users').createIndex({ iamId: 1 }, { sparse: true }),
|
|
98
|
+
// Account: userId lookup ($lookup in getMigrationStatus) and providerId filtering
|
|
99
|
+
db.collection('account').createIndex({ userId: 1 }),
|
|
100
|
+
db.collection('account').createIndex({ providerId: 1, userId: 1 }),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
this.logger.debug('Performance indices ensured on session, users, and account collections');
|
|
100
104
|
} catch (error) {
|
|
101
105
|
// Non-fatal: indices improve performance but are not required for correctness
|
|
102
106
|
this.logger.warn(`Could not create performance indices: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
@@ -121,7 +121,18 @@ Normal (non-hierarchy) roles also work:
|
|
|
121
121
|
async viewAuditLog(...) { ... }
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
### 8.
|
|
124
|
+
### 8. System Roles as OR Alternatives
|
|
125
|
+
|
|
126
|
+
System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives **before** real role checks in `CoreTenantGuard`. If a system role grants access, real roles in the same `@Roles()` are alternatives, not requirements.
|
|
127
|
+
|
|
128
|
+
When `X-Tenant-Id` header is present and a system role grants access:
|
|
129
|
+
|
|
130
|
+
- `S_EVERYONE`: public — membership is optionally enriched but never blocks
|
|
131
|
+
- `S_USER`/`S_VERIFIED`: membership is validated (403 if not a member; admin bypass applies)
|
|
132
|
+
|
|
133
|
+
`@SkipTenantCheck()` suppresses membership validation for `S_USER`/`S_VERIFIED` paths — authentication/verification is still enforced, but no membership check runs even with a header present.
|
|
134
|
+
|
|
135
|
+
### 9. Skip Tenant Check for Non-Tenant Endpoints
|
|
125
136
|
|
|
126
137
|
Use `@SkipTenantCheck()` for endpoints that intentionally work without tenant context:
|
|
127
138
|
|
|
@@ -133,6 +144,23 @@ import { SkipTenantCheck, Roles, RoleEnum } from '@lenne.tech/nest-server';
|
|
|
133
144
|
async listMyTenants() { ... }
|
|
134
145
|
```
|
|
135
146
|
|
|
147
|
+
### 10. BetterAuth (IAM) Coexistence
|
|
148
|
+
|
|
149
|
+
When both `multiTenancy` and `betterAuth` are active, IAM endpoints (sign-in, sign-up, session, etc.)
|
|
150
|
+
automatically skip tenant validation when no `X-Tenant-Id` header is sent (`betterAuth.skipTenantCheck: true`, default).
|
|
151
|
+
If a header IS present, normal membership validation runs.
|
|
152
|
+
|
|
153
|
+
For tenant-aware authentication (subdomain-based, invite links, SSO per tenant), opt out:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// config.env.ts
|
|
157
|
+
betterAuth: {
|
|
158
|
+
skipTenantCheck: false, // IAM endpoints require valid X-Tenant-Id header
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
See [Tenant README — BetterAuth Integration](./README.md#betterauth-iam-integration) for details.
|
|
163
|
+
|
|
136
164
|
## Verification Checklist
|
|
137
165
|
|
|
138
166
|
- [ ] `pnpm run build` succeeds
|
|
@@ -143,6 +171,7 @@ async listMyTenants() { ... }
|
|
|
143
171
|
- [ ] Non-member gets 403 "Not a member of this tenant"
|
|
144
172
|
- [ ] Unauthenticated + header → 403 "Authentication required for tenant access"
|
|
145
173
|
- [ ] Public endpoint accessing tenantId-schema without context throws 403 (Safety Net)
|
|
174
|
+
- [ ] IAM endpoints work without `X-Tenant-Id` header when `skipTenantCheck: true` (default)
|
|
146
175
|
|
|
147
176
|
## Security
|
|
148
177
|
|
|
@@ -162,4 +191,5 @@ async listMyTenants() { ... }
|
|
|
162
191
|
| Querying membership without bypass | Empty results due to tenant filter | Use `RequestContext.runWithBypassTenantGuard()` |
|
|
163
192
|
| Public endpoint accessing tenantId-schema | 403 Safety Net exception | Use `@SkipTenantCheck()` + `RequestContext.runWithBypassTenantGuard()` |
|
|
164
193
|
| Passing user-supplied tenantId to create() | Cross-tenant write possible | Let plugin auto-set tenantId from context |
|
|
165
|
-
| Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
|
|
194
|
+
| Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
|
|
195
|
+
| `@SkipTenantCheck()` on BetterAuth handler | Redundant since v11.21.2 | Remove — auto-skip handles this via `betterAuth.skipTenantCheck` (default) |
|
|
@@ -138,9 +138,33 @@ Roles not in `roleHierarchy` use exact match — no higher role can compensate:
|
|
|
138
138
|
async auditLog(@CurrentTenant() tenantId: string) { ... }
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
### System Roles as OR Alternatives
|
|
142
|
+
|
|
143
|
+
When `@Roles()` includes system roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`), `CoreTenantGuard` checks them **before** real roles in priority order. If a system role is satisfied, access is granted immediately — real roles in the same decorator are OR alternatives, not additional requirements.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
@Roles(RoleEnum.ADMIN) // Class: admin can always access all methods
|
|
147
|
+
@Controller('users')
|
|
148
|
+
class UserController {
|
|
149
|
+
@Roles(RoleEnum.S_USER) // Method: any authenticated user (extends class ADMIN via OR)
|
|
150
|
+
getProfile() { ... } // → admin OR authenticated user can access
|
|
151
|
+
|
|
152
|
+
@Roles(RoleEnum.S_EVERYONE) // Method: public endpoint (extends class ADMIN via OR)
|
|
153
|
+
getPublicInfo() { ... } // → anyone can access
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Method-level system roles take precedence** for the system role check. Class `@Roles(S_EVERYONE)` does not make a method `@Roles(S_USER)` endpoint public — the method's `S_USER` applies.
|
|
158
|
+
|
|
159
|
+
| System Role | No Header | Header Present |
|
|
160
|
+
| ----------- | --------------------- | -------------------------------------------------- |
|
|
161
|
+
| S_EVERYONE | Pass (public) | Pass + optional tenant context enrichment |
|
|
162
|
+
| S_USER | Pass if authenticated | Pass if authenticated member; admin bypass applies |
|
|
163
|
+
| S_VERIFIED | Pass if verified | Pass if verified member; admin bypass applies |
|
|
164
|
+
|
|
141
165
|
### Tenant Context Rule
|
|
142
166
|
|
|
143
|
-
**When a tenant header is present:**
|
|
167
|
+
**When a tenant header is present:** For real role checks, only `membership.role` is checked. `user.roles` is ignored (except ADMIN bypass). For system role checks (`S_USER`, `S_VERIFIED`), membership is validated to set tenant context (`tenantId`, `tenantRole`), but access depends on membership existence, not role level.
|
|
144
168
|
|
|
145
169
|
**When no tenant header:** `user.roles` is checked instead. Hierarchy roles use level comparison, normal roles use exact match.
|
|
146
170
|
|
|
@@ -161,6 +185,7 @@ async listMyTenants() { ... }
|
|
|
161
185
|
```
|
|
162
186
|
|
|
163
187
|
Note: `@SkipTenantCheck()` with hierarchy roles still checks `user.roles` (no tenant context).
|
|
188
|
+
It also suppresses tenant membership validation for `S_USER`/`S_VERIFIED` system role paths — when set, these roles still enforce authentication/verification but skip the membership check even when a tenant header is present.
|
|
164
189
|
|
|
165
190
|
### Admin Bypass
|
|
166
191
|
|
|
@@ -207,6 +232,7 @@ multiTenancy: {
|
|
|
207
232
|
```
|
|
208
233
|
|
|
209
234
|
**Cache behavior:**
|
|
235
|
+
|
|
210
236
|
- **Positive-only:** Only active memberships are cached. Non-member lookups always hit the DB (security-first).
|
|
211
237
|
- **Auto-invalidation:** `CoreTenantService.addMember/removeMember/updateMemberRole` automatically invalidate the cache.
|
|
212
238
|
- **Config-change detection:** Cache is flushed when `multiTenancy` config changes (e.g., `roleHierarchy` update).
|
|
@@ -261,8 +287,40 @@ export class TenantMemberController {
|
|
|
261
287
|
}
|
|
262
288
|
```
|
|
263
289
|
|
|
290
|
+
### BetterAuth (IAM) Integration
|
|
291
|
+
|
|
292
|
+
When both Multi-Tenancy and BetterAuth are active, IAM endpoints (sign-up, sign-in, sign-out,
|
|
293
|
+
session, etc.) automatically skip tenant validation by default. This is because authentication
|
|
294
|
+
typically happens before tenant context is established.
|
|
295
|
+
|
|
296
|
+
**Behavior with `skipTenantCheck: true` (default):**
|
|
297
|
+
|
|
298
|
+
- No `X-Tenant-Id` header → skip tenant validation, proceed without tenant context
|
|
299
|
+
- `X-Tenant-Id` header IS provided → validate normally (membership check, tenant context set)
|
|
300
|
+
|
|
301
|
+
The tenant is optional but respected when present. This allows tenant-aware auth flows
|
|
302
|
+
(subdomain-based, invite links, etc.) to coexist with the default cross-tenant behavior.
|
|
303
|
+
|
|
304
|
+
**Configuration:**
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
// config.env.ts — Default: skip tenant validation on IAM endpoints (most projects)
|
|
308
|
+
betterAuth: {
|
|
309
|
+
skipTenantCheck: true, // (default) No X-Tenant-Id header → skip; header present → validate
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// config.env.ts — Tenant-aware auth (subdomain-based, invite links, SSO per tenant)
|
|
313
|
+
betterAuth: {
|
|
314
|
+
skipTenantCheck: false, // IAM endpoints ALWAYS require valid X-Tenant-Id header
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
When `skipTenantCheck: false`, IAM endpoints will require a valid `X-Tenant-Id` header
|
|
319
|
+
and the user must be a member of that tenant for protected endpoints.
|
|
320
|
+
|
|
264
321
|
## Related
|
|
265
322
|
|
|
266
323
|
- [Integration Checklist](./INTEGRATION-CHECKLIST.md)
|
|
267
324
|
- [Configurable Features](../../../.claude/rules/configurable-features.md)
|
|
325
|
+
|
|
268
326
|
- [Request Lifecycle](../../../docs/REQUEST-LIFECYCLE.md)
|
|
@@ -47,16 +47,31 @@ interface CachedTenantIds {
|
|
|
47
47
|
* - Plugin level: Safety net — ForbiddenException when tenantId-schema accessed without context
|
|
48
48
|
*
|
|
49
49
|
* Role check semantics:
|
|
50
|
+
* - System roles are OR alternatives, checked in order before real roles:
|
|
51
|
+
* S_EVERYONE → immediate pass; S_USER → pass if authenticated; S_VERIFIED → pass if verified
|
|
52
|
+
* - When a system role grants access and X-Tenant-Id header is present, membership is still
|
|
53
|
+
* validated to set tenant context (tenantId + tenantRole) on the request.
|
|
50
54
|
* - Hierarchy roles (in roleHierarchy config): level comparison — higher includes lower
|
|
51
55
|
* - Normal roles (not in roleHierarchy): exact match — no compensation by higher role
|
|
52
56
|
* - Tenant context (header present): checks against membership.role only (user.roles ignored)
|
|
53
57
|
* - No tenant context: checks against user.roles
|
|
54
58
|
*
|
|
59
|
+
* BetterAuth (IAM) auto-skip behavior (skipTenantCheck config, default: true):
|
|
60
|
+
* - No X-Tenant-Id header: skip tenant validation entirely (auth before tenant is the expected case)
|
|
61
|
+
* - X-Tenant-Id header IS present: fall through to normal validation (membership check + context)
|
|
62
|
+
* This allows tenant-aware auth flows (subdomain-based, invite links, etc.) to coexist with
|
|
63
|
+
* the default cross-tenant behavior. The tenant is optional but respected when provided.
|
|
64
|
+
*
|
|
55
65
|
* Flow:
|
|
56
66
|
* 1. Config check: multiTenancy enabled?
|
|
57
67
|
* 2. Parse header (X-Tenant-Id, max 128 chars, trimmed)
|
|
58
|
-
* 3. @
|
|
59
|
-
* 4.
|
|
68
|
+
* 3. Read @Roles() metadata (method + class level)
|
|
69
|
+
* 4. System role early-exit checks (OR alternatives):
|
|
70
|
+
* S_EVERYONE → pass immediately
|
|
71
|
+
* S_USER → pass if authenticated (+ optional membership check when header present)
|
|
72
|
+
* S_VERIFIED → pass if user is verified (+ optional membership check when header present)
|
|
73
|
+
* 5. @SkipTenantCheck → role check against user.roles, no tenant context
|
|
74
|
+
* 6. BetterAuth auto-skip (betterAuth.skipTenantCheck config + no header) → skip, no tenant context
|
|
60
75
|
*
|
|
61
76
|
* HEADER PRESENT:
|
|
62
77
|
* - System ADMIN (adminBypass: true) → set req.tenantId + isAdminBypass
|
|
@@ -177,33 +192,137 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
177
192
|
const headerTenantId =
|
|
178
193
|
rawHeader && typeof rawHeader === 'string' && rawHeader.length <= 128 ? rawHeader.trim() : undefined;
|
|
179
194
|
|
|
180
|
-
//
|
|
195
|
+
// Two role sets for different purposes:
|
|
196
|
+
//
|
|
197
|
+
// 1. systemCheckRoles (method-takes-precedence): Used for system role early-returns.
|
|
198
|
+
// Method-level system roles override class-level ones to prevent e.g. class @Roles(S_EVERYONE)
|
|
199
|
+
// from making a method @Roles(S_USER) endpoint public.
|
|
200
|
+
//
|
|
201
|
+
// 2. roles (OR/merged): Used for real role checks (checkableRoles).
|
|
202
|
+
// Class-level roles serve as a base that method-level roles extend.
|
|
203
|
+
// E.g., class @Roles(ADMIN) + method @Roles('editor') → both are alternatives.
|
|
204
|
+
//
|
|
205
|
+
// S_EVERYONE check — access is always granted; no authentication or membership required.
|
|
206
|
+
//
|
|
207
|
+
// Header handling for S_EVERYONE:
|
|
208
|
+
// - No header → return true immediately (no tenant context needed)
|
|
209
|
+
// - Header present + authenticated user that IS a member → optionally enrich with tenant
|
|
210
|
+
// context (sets request.tenantId/tenantRole) so downstream consumers can use it.
|
|
211
|
+
// Access is NOT blocked if user is not a member — S_EVERYONE means public access.
|
|
181
212
|
const rolesMetadata = this.reflector.getAll<string[][]>('roles', [context.getHandler(), context.getClass()]);
|
|
182
213
|
const roles = mergeRolesMetadata(rolesMetadata);
|
|
214
|
+
const methodRoles: string[] = rolesMetadata[0] ?? [];
|
|
215
|
+
const systemCheckRoles = methodRoles.length > 0 ? methodRoles : roles;
|
|
216
|
+
|
|
217
|
+
// Defense-in-depth: S_NO_ONE is normally caught by RolesGuard/BetterAuthRolesGuard upstream,
|
|
218
|
+
// but guard it here too in case CoreTenantGuard runs standalone (e.g., custom guard chains).
|
|
219
|
+
if (roles.includes(RoleEnum.S_NO_ONE)) {
|
|
220
|
+
throw new ForbiddenException('Access denied');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const sEveryoneGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_EVERYONE);
|
|
224
|
+
if (sEveryoneGrantsAccess) {
|
|
225
|
+
// Optionally enrich with tenant context when header is present and user is an active member.
|
|
226
|
+
// Never block access — S_EVERYONE endpoints are always public.
|
|
227
|
+
if (headerTenantId && request.user?.id) {
|
|
228
|
+
const membership = await this.findMembershipCached(request.user.id, headerTenantId);
|
|
229
|
+
if (membership) {
|
|
230
|
+
request.tenantId = headerTenantId;
|
|
231
|
+
request.tenantRole = membership.role as string;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
183
236
|
|
|
184
237
|
const user = request.user;
|
|
185
238
|
const adminBypass = config.adminBypass !== false;
|
|
186
239
|
const isAdmin = adminBypass && user?.roles?.includes(RoleEnum.ADMIN);
|
|
187
240
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
// @SkipTenantCheck → no tenant context, but role check against user.roles
|
|
194
|
-
const skipTenantCheck = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
|
|
241
|
+
// Read @SkipTenantCheck early — it suppresses tenant membership validation for system roles too.
|
|
242
|
+
// When set, S_USER and S_VERIFIED still enforce authentication/verification, but no membership
|
|
243
|
+
// check is performed even when a tenant header is present.
|
|
244
|
+
const hasSkipDecorator = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
|
|
195
245
|
context.getHandler(),
|
|
196
246
|
context.getClass(),
|
|
197
247
|
]);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
248
|
+
|
|
249
|
+
// S_USER check — any authenticated user satisfies this system role.
|
|
250
|
+
//
|
|
251
|
+
// OR semantics: if S_USER is in the active role set, a logged-in user gets through.
|
|
252
|
+
// Real roles in the same @Roles() are ignored when S_USER is satisfied (they are alternatives).
|
|
253
|
+
// Example: @Roles(S_USER, 'owner') → a plain logged-in user passes (owner is an alternative, not required).
|
|
254
|
+
//
|
|
255
|
+
// Tenant header behavior: when X-Tenant-Id is present and @SkipTenantCheck is NOT set,
|
|
256
|
+
// membership is validated so that tenant context (tenantId, tenantRole) is set on the request.
|
|
257
|
+
// A non-member will still get 403 when a tenant header is provided (unless @SkipTenantCheck).
|
|
258
|
+
const sUserGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_USER);
|
|
259
|
+
if (sUserGrantsAccess) {
|
|
260
|
+
if (!user) {
|
|
261
|
+
throw new ForbiddenException('Authentication required');
|
|
262
|
+
}
|
|
263
|
+
if (headerTenantId && !hasSkipDecorator) {
|
|
264
|
+
return this.handleSystemRoleWithTenantHeader(user, headerTenantId, request, isAdmin);
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// S_VERIFIED check — any verified authenticated user satisfies this system role.
|
|
270
|
+
//
|
|
271
|
+
// A user is considered verified when any of these properties is truthy:
|
|
272
|
+
// user.verified, user.verifiedAt, user.emailVerified
|
|
273
|
+
//
|
|
274
|
+
// Tenant header behavior: same as S_USER — membership is validated when header is present
|
|
275
|
+
// (unless @SkipTenantCheck is set).
|
|
276
|
+
const sVerifiedGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_VERIFIED);
|
|
277
|
+
if (sVerifiedGrantsAccess) {
|
|
278
|
+
if (!user) {
|
|
279
|
+
throw new ForbiddenException('Authentication required');
|
|
280
|
+
}
|
|
281
|
+
const isVerified = !!(user.verified || user.verifiedAt || user.emailVerified);
|
|
282
|
+
if (!isVerified) {
|
|
283
|
+
throw new ForbiddenException('Verification required');
|
|
284
|
+
}
|
|
285
|
+
if (headerTenantId && !hasSkipDecorator) {
|
|
286
|
+
return this.handleSystemRoleWithTenantHeader(user, headerTenantId, request, isAdmin);
|
|
203
287
|
}
|
|
204
288
|
return true;
|
|
205
289
|
}
|
|
206
290
|
|
|
291
|
+
// Extract checkable (non-system) roles from the merged set.
|
|
292
|
+
// System roles that grant access (S_EVERYONE, S_USER, S_VERIFIED) have been
|
|
293
|
+
// early-returned above. Remaining system roles (S_SELF, S_CREATOR) are object-level
|
|
294
|
+
// and handled by interceptors.
|
|
295
|
+
const checkableRoles = roles.filter((r: string) => !isSystemRole(r));
|
|
296
|
+
|
|
297
|
+
const minRequiredLevel = checkableRoles.length > 0 ? getMinRequiredLevel(checkableRoles) : undefined;
|
|
298
|
+
|
|
299
|
+
// @SkipTenantCheck decorator → no tenant context, but role check against user.roles
|
|
300
|
+
if (hasSkipDecorator) {
|
|
301
|
+
return this.skipWithUserRoleCheck(checkableRoles, user, isAdmin);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Auto-skip tenant check for BetterAuth (IAM) handlers when configured,
|
|
305
|
+
// but ONLY when no X-Tenant-Id header is present.
|
|
306
|
+
// - No header: skip tenant validation (auth before tenant is the expected case for most projects)
|
|
307
|
+
// - Header present: fall through to normal validation (membership check, tenant context set)
|
|
308
|
+
// This allows tenant-aware auth scenarios to coexist with the default cross-tenant behavior.
|
|
309
|
+
// Default config: betterAuth.skipTenantCheck = true (note: distinct from @SkipTenantCheck decorator above).
|
|
310
|
+
if (!hasSkipDecorator && !headerTenantId && this.isBetterAuthHandler(context)) {
|
|
311
|
+
const betterAuthConfig = ConfigService.configFastButReadOnly?.betterAuth;
|
|
312
|
+
// Boolean shorthand: `betterAuth: true` → skip, `betterAuth: false` → no skip
|
|
313
|
+
const shouldSkip =
|
|
314
|
+
betterAuthConfig !== null && betterAuthConfig !== undefined && typeof betterAuthConfig === 'object'
|
|
315
|
+
? betterAuthConfig.skipTenantCheck !== false // default: true
|
|
316
|
+
: betterAuthConfig !== false; // true/undefined → skip; false → no skip
|
|
317
|
+
|
|
318
|
+
if (shouldSkip) {
|
|
319
|
+
this.logger.debug(
|
|
320
|
+
`BetterAuth auto-skip: ${context.getClass().name}::${context.getHandler().name} — no X-Tenant-Id header, skipping tenant validation`,
|
|
321
|
+
);
|
|
322
|
+
return this.skipWithUserRoleCheck(checkableRoles, user, isAdmin);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
207
326
|
// === HEADER PRESENT ===
|
|
208
327
|
if (headerTenantId) {
|
|
209
328
|
// Admin bypass: set req.tenantId so plugin filters (read by RequestContextMiddleware
|
|
@@ -212,7 +331,9 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
212
331
|
request.tenantId = headerTenantId;
|
|
213
332
|
request.isAdminBypass = true;
|
|
214
333
|
const requiredRole = checkableRoles.length > 0 ? checkableRoles.join(',') : 'none';
|
|
215
|
-
|
|
334
|
+
// Sanitize control characters to prevent log injection
|
|
335
|
+
const safeTenantId = headerTenantId.replace(/[\r\n\t]/g, '_');
|
|
336
|
+
this.logger.debug(`Admin bypass: user ${user.id} accessing tenant ${safeTenantId} (required: ${requiredRole})`);
|
|
216
337
|
return true;
|
|
217
338
|
}
|
|
218
339
|
|
|
@@ -438,4 +559,90 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
438
559
|
}
|
|
439
560
|
}
|
|
440
561
|
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Validate tenant membership for a request that was granted access via a system role
|
|
565
|
+
* (S_USER or S_VERIFIED). When a tenant header is present, the user must be an active member
|
|
566
|
+
* of that tenant — unless the user is an admin with adminBypass enabled.
|
|
567
|
+
*
|
|
568
|
+
* On success, sets request.tenantId and request.tenantRole for downstream consumers.
|
|
569
|
+
*
|
|
570
|
+
* @param user - The authenticated request user
|
|
571
|
+
* @param headerTenantId - The validated, non-empty tenant ID from the request header
|
|
572
|
+
* @param request - The HTTP/GraphQL request object
|
|
573
|
+
* @param isAdmin - Whether the user has admin bypass privileges
|
|
574
|
+
*/
|
|
575
|
+
private async handleSystemRoleWithTenantHeader(
|
|
576
|
+
user: any,
|
|
577
|
+
headerTenantId: string,
|
|
578
|
+
request: any,
|
|
579
|
+
isAdmin: boolean,
|
|
580
|
+
): Promise<true> {
|
|
581
|
+
// Admin bypass: same behavior as the HEADER PRESENT admin path below
|
|
582
|
+
if (isAdmin) {
|
|
583
|
+
request.tenantId = headerTenantId;
|
|
584
|
+
request.isAdminBypass = true;
|
|
585
|
+
const safeTenantId = headerTenantId.replace(/[\r\n\t]/g, '_');
|
|
586
|
+
this.logger.debug(`Admin bypass (system-role path): user ${user.id} accessing tenant ${safeTenantId}`);
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const membership = await this.findMembershipCached(user.id, headerTenantId);
|
|
591
|
+
if (!membership) {
|
|
592
|
+
throw new ForbiddenException('Not a member of this tenant');
|
|
593
|
+
}
|
|
594
|
+
request.tenantId = headerTenantId;
|
|
595
|
+
request.tenantRole = membership.role as string;
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Skip tenant validation but still check non-system roles against user.roles.
|
|
601
|
+
* Shared by @SkipTenantCheck decorator path and BetterAuth auto-skip path.
|
|
602
|
+
*/
|
|
603
|
+
private skipWithUserRoleCheck(checkableRoles: string[], user: any, isAdmin: boolean): true {
|
|
604
|
+
if (checkableRoles.length > 0) {
|
|
605
|
+
// Defense-in-depth: reject unauthenticated access even if RolesGuard is absent
|
|
606
|
+
if (!user) {
|
|
607
|
+
throw new ForbiddenException('Authentication required');
|
|
608
|
+
}
|
|
609
|
+
if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
610
|
+
throw new ForbiddenException('Insufficient role');
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Check if the current request is handled by a BetterAuth (IAM) handler
|
|
618
|
+
* (controller or resolver). Used for auto-skip tenant check on IAM endpoints.
|
|
619
|
+
*
|
|
620
|
+
* Uses require() instead of import to avoid a circular dependency:
|
|
621
|
+
* tenant module → better-auth module (which may depend on tenant module indirectly).
|
|
622
|
+
* The require() is lazy and resolved only when needed (Node.js caches the result).
|
|
623
|
+
*/
|
|
624
|
+
private isBetterAuthHandler(context: ExecutionContext): boolean {
|
|
625
|
+
const handler = context.getClass();
|
|
626
|
+
try {
|
|
627
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
628
|
+
const { CoreBetterAuthController } =
|
|
629
|
+
require('../better-auth/core-better-auth.controller') as typeof import('../better-auth/core-better-auth.controller');
|
|
630
|
+
if (handler === CoreBetterAuthController || handler.prototype instanceof CoreBetterAuthController) {
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
} catch {
|
|
634
|
+
/* BetterAuth controller not available */
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
638
|
+
const { CoreBetterAuthResolver } =
|
|
639
|
+
require('../better-auth/core-better-auth.resolver') as typeof import('../better-auth/core-better-auth.resolver');
|
|
640
|
+
if (handler === CoreBetterAuthResolver || handler.prototype instanceof CoreBetterAuthResolver) {
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
} catch {
|
|
644
|
+
/* BetterAuth resolver not available */
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
441
648
|
}
|