@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
|
@@ -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
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Tenant Module Integration Checklist
|
|
2
|
+
|
|
3
|
+
## Reference Implementation
|
|
4
|
+
|
|
5
|
+
- Local: `node_modules/@lenne.tech/nest-server/src/server/modules/tenant/` (when available)
|
|
6
|
+
- GitHub: https://github.com/lenneTech/nest-server/tree/develop/src/core/modules/tenant
|
|
7
|
+
|
|
8
|
+
## Quick Start (Auto-Registration)
|
|
9
|
+
|
|
10
|
+
For projects that don't need custom tenant logic:
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// config.env.ts
|
|
14
|
+
multiTenancy: {},
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That's it. CoreModule auto-registers the tenant module, guard, and service.
|
|
18
|
+
|
|
19
|
+
> **Note:** Auto-registration uses default model/guard/service. For custom implementations, use manual registration with `CoreTenantModule.forRoot({ service: CustomTenantService })` in your ServerModule.
|
|
20
|
+
|
|
21
|
+
## Custom Integration (Manual Registration)
|
|
22
|
+
|
|
23
|
+
### 1. Create TenantMember Model
|
|
24
|
+
|
|
25
|
+
**Create:** `src/server/modules/tenant/tenant-member.model.ts`
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { Schema } from '@nestjs/mongoose';
|
|
29
|
+
import { CoreTenantMemberModel } from '@lenne.tech/nest-server';
|
|
30
|
+
|
|
31
|
+
@Schema({ timestamps: true })
|
|
32
|
+
export class TenantMember extends CoreTenantMemberModel {
|
|
33
|
+
// Add custom fields here if needed
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Create Custom Service (Optional)
|
|
38
|
+
|
|
39
|
+
**Create:** `src/server/modules/tenant/tenant.service.ts`
|
|
40
|
+
|
|
41
|
+
Extend `CoreTenantService` to add custom logic (notifications, tenant creation, etc.).
|
|
42
|
+
|
|
43
|
+
### 3. Register in ServerModule
|
|
44
|
+
|
|
45
|
+
**Modify:** `src/server/server.module.ts`
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { CoreTenantModule } from '@lenne.tech/nest-server';
|
|
49
|
+
import { TenantMember } from './modules/tenant/tenant-member.model';
|
|
50
|
+
import { TenantService } from './modules/tenant/tenant.service';
|
|
51
|
+
|
|
52
|
+
@Module({
|
|
53
|
+
imports: [
|
|
54
|
+
CoreTenantModule.forRoot({
|
|
55
|
+
memberModel: TenantMember,
|
|
56
|
+
service: TenantService,
|
|
57
|
+
}),
|
|
58
|
+
// ...
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
export class ServerModule {}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 4. Define Hierarchy Roles (Recommended)
|
|
65
|
+
|
|
66
|
+
**Create:** `src/server/modules/tenant/roles.ts`
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { createHierarchyRoles } from '@lenne.tech/nest-server';
|
|
70
|
+
|
|
71
|
+
// Must match roleHierarchy in config.env.ts (or use DefaultHR for defaults)
|
|
72
|
+
export const HR = createHierarchyRoles({ member: 1, manager: 2, owner: 3 });
|
|
73
|
+
// HR.MEMBER = 'member', HR.MANAGER = 'manager', HR.OWNER = 'owner'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or use the built-in `DefaultHR` for the default hierarchy:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { DefaultHR } from '@lenne.tech/nest-server';
|
|
80
|
+
// DefaultHR.MEMBER, DefaultHR.MANAGER, DefaultHR.OWNER
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 5. Add Tenant Header to API Calls
|
|
84
|
+
|
|
85
|
+
All tenant-scoped requests must include:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
X-Tenant-Id: <tenant-id>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 6. Add tenantId to Scoped Models
|
|
92
|
+
|
|
93
|
+
Models that should be tenant-scoped need a `tenantId` field:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
@Prop({ type: String })
|
|
97
|
+
tenantId: string;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The tenant plugin automatically filters and populates this field.
|
|
101
|
+
|
|
102
|
+
### 7. Use Hierarchy Roles on Protected Endpoints
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { Roles, CurrentTenant, DefaultHR } from '@lenne.tech/nest-server';
|
|
106
|
+
|
|
107
|
+
@Roles(DefaultHR.MEMBER) // any active member
|
|
108
|
+
async listItems(@CurrentTenant() tenantId: string) { ... }
|
|
109
|
+
|
|
110
|
+
@Roles(DefaultHR.MANAGER) // at least manager level
|
|
111
|
+
async updateItem(...) { ... }
|
|
112
|
+
|
|
113
|
+
@Roles(DefaultHR.OWNER) // highest level only
|
|
114
|
+
async deleteItem(...) { ... }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Normal (non-hierarchy) roles also work:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
@Roles('auditor') // exact match against membership.role
|
|
121
|
+
async viewAuditLog(...) { ... }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 8. Skip Tenant Check for Non-Tenant Endpoints
|
|
125
|
+
|
|
126
|
+
Use `@SkipTenantCheck()` for endpoints that intentionally work without tenant context:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { SkipTenantCheck, Roles, RoleEnum } from '@lenne.tech/nest-server';
|
|
130
|
+
|
|
131
|
+
@SkipTenantCheck()
|
|
132
|
+
@Roles(RoleEnum.S_USER)
|
|
133
|
+
async listMyTenants() { ... }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Verification Checklist
|
|
137
|
+
|
|
138
|
+
- [ ] `pnpm run build` succeeds
|
|
139
|
+
- [ ] `pnpm test` passes
|
|
140
|
+
- [ ] Request with `X-Tenant-Id` header only returns tenant data
|
|
141
|
+
- [ ] Request without header + hierarchy role checks user.roles and filters tenantIds
|
|
142
|
+
- [ ] Admin user can access any tenant (if adminBypass: true)
|
|
143
|
+
- [ ] Non-member gets 403 "Not a member of this tenant"
|
|
144
|
+
- [ ] Unauthenticated + header → 403 "Authentication required for tenant access"
|
|
145
|
+
- [ ] Public endpoint accessing tenantId-schema without context throws 403 (Safety Net)
|
|
146
|
+
|
|
147
|
+
## Security
|
|
148
|
+
|
|
149
|
+
> **Defense-in-Depth:** The guard validates membership and sets `req.tenantId`. The Mongoose plugin only uses this validated value (via RequestContext) — never the raw header. Additionally, the plugin's Safety Net throws `ForbiddenException` when a tenantId-scoped schema is accessed without valid tenant context.
|
|
150
|
+
|
|
151
|
+
> **Tenant overrides user.roles:** When a tenant header is present, only `membership.role` is checked. `user.roles` is ignored (except ADMIN bypass). This prevents users from claiming higher privileges via `user.roles` when operating within a specific tenant.
|
|
152
|
+
|
|
153
|
+
> **Cross-tenant writes:** Never pass user-supplied `tenantId` directly to `create()` or `new Model()`. The plugin only auto-sets `tenantId` on new documents when no explicit value is provided. Accepting user-supplied values bypasses the auto-set and could allow cross-tenant writes.
|
|
154
|
+
|
|
155
|
+
## Common Mistakes
|
|
156
|
+
|
|
157
|
+
| Mistake | Symptom | Fix |
|
|
158
|
+
| ------------------------------------------------ | ---------------------------------- | -------------------------------------------------------------------------- |
|
|
159
|
+
| Missing `tenantId` field on model | Data not filtered by tenant | Add `@Prop({ type: String }) tenantId: string` |
|
|
160
|
+
| Forgetting `X-Tenant-Id` header | 403 or Safety Net exception | Add header to all tenant-scoped API calls |
|
|
161
|
+
| Using only `@Roles(S_USER)` for tenant endpoints | No tenant membership check | Use `@Roles(DefaultHR.MEMBER)` for tenant-level access |
|
|
162
|
+
| Querying membership without bypass | Empty results due to tenant filter | Use `RequestContext.runWithBypassTenantGuard()` |
|
|
163
|
+
| Public endpoint accessing tenantId-schema | 403 Safety Net exception | Use `@SkipTenantCheck()` + `RequestContext.runWithBypassTenantGuard()` |
|
|
164
|
+
| 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` |
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# CoreTenantModule
|
|
2
|
+
|
|
3
|
+
Header-based multi-tenancy for @lenne.tech/nest-server.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Enables tenant-based data isolation where:
|
|
8
|
+
|
|
9
|
+
- Users can be members of multiple tenants
|
|
10
|
+
- The active tenant is selected per-request via `X-Tenant-Id` header
|
|
11
|
+
- Data is automatically filtered by tenant (via the existing Mongoose tenant plugin)
|
|
12
|
+
- Tenant membership and roles are validated per-request
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Request + X-Tenant-Id Header
|
|
18
|
+
-> RequestContextMiddleware (lazy getters for tenant context)
|
|
19
|
+
-> Auth Middleware (sets req.user)
|
|
20
|
+
-> CoreTenantGuard (validates membership, sets req.tenantId)
|
|
21
|
+
-> Mongoose Tenant Plugin (filters queries by context.tenantId)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Defense-in-depth:** Two layers of protection:
|
|
25
|
+
|
|
26
|
+
1. **Guard level:** CoreTenantGuard validates membership and sets `tenantId` (only after successful check)
|
|
27
|
+
2. **Plugin level:** Safety Net — throws `ForbiddenException` when a tenantId-scoped schema is accessed without valid tenant context
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// config.env.ts
|
|
33
|
+
|
|
34
|
+
// Disabled (default) - zero overhead
|
|
35
|
+
// multiTenancy: undefined
|
|
36
|
+
|
|
37
|
+
// Enabled with defaults
|
|
38
|
+
multiTenancy: {},
|
|
39
|
+
|
|
40
|
+
// Enabled with custom settings
|
|
41
|
+
multiTenancy: {
|
|
42
|
+
headerName: 'x-tenant-id', // Header name (default: 'x-tenant-id')
|
|
43
|
+
membershipModel: 'TenantMember', // Mongoose model name (default)
|
|
44
|
+
adminBypass: true, // System admins bypass membership (default: true)
|
|
45
|
+
excludeSchemas: ['User', 'Session'], // Schemas without tenant filtering
|
|
46
|
+
roleHierarchy: { // Custom role hierarchy (default below)
|
|
47
|
+
member: 1,
|
|
48
|
+
manager: 2,
|
|
49
|
+
owner: 3,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Pre-configured but disabled
|
|
54
|
+
multiTenancy: { enabled: false },
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Components
|
|
58
|
+
|
|
59
|
+
| Component | Purpose |
|
|
60
|
+
| ------------------------ | ------------------------------------------------------ |
|
|
61
|
+
| `CoreTenantMemberModel` | User-tenant membership (join table with role + status) |
|
|
62
|
+
| `CoreTenantGuard` | APP_GUARD validating tenant header + membership |
|
|
63
|
+
| `CoreTenantService` | Membership CRUD (add, remove, update role, find) |
|
|
64
|
+
| `@SkipTenantCheck()` | Method/class decorator to skip tenant validation |
|
|
65
|
+
| `@CurrentTenant()` | Parameter decorator extracting validated tenant ID |
|
|
66
|
+
| `TenantMemberStatus` | Enum: ACTIVE, INVITED, SUSPENDED |
|
|
67
|
+
| `DefaultHR` | Type-safe constants for default hierarchy roles |
|
|
68
|
+
| `createHierarchyRoles()` | Generate type-safe constants from custom hierarchy |
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
### Protecting Endpoints with Hierarchy Roles
|
|
73
|
+
|
|
74
|
+
Use `@Roles()` with hierarchy role strings. Higher levels include lower levels (level comparison).
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { Roles, CurrentTenant, DefaultHR } from '@lenne.tech/nest-server';
|
|
78
|
+
|
|
79
|
+
@Controller('projects')
|
|
80
|
+
export class ProjectController {
|
|
81
|
+
@Get()
|
|
82
|
+
@Roles(DefaultHR.MEMBER) // any active member (level >= 1)
|
|
83
|
+
async list(@CurrentTenant() tenantId: string) {
|
|
84
|
+
return this.projectService.find({ tenantId });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Put(':id')
|
|
88
|
+
@Roles(DefaultHR.MANAGER) // at least manager level (level >= 2)
|
|
89
|
+
async update(@Param('id') id: string, @Body() body: UpdateDto) {
|
|
90
|
+
return this.projectService.update(id, body);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Delete(':id')
|
|
94
|
+
@Roles(DefaultHR.OWNER) // highest level only (level >= 3)
|
|
95
|
+
async delete(@Param('id') id: string) {
|
|
96
|
+
return this.projectService.delete(id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Hierarchy Roles
|
|
102
|
+
|
|
103
|
+
The role hierarchy defines levels where higher includes lower:
|
|
104
|
+
|
|
105
|
+
| Role Key | Level | Can Access |
|
|
106
|
+
| --------- | ----- | -------------------------- |
|
|
107
|
+
| `owner` | 3 | Everything |
|
|
108
|
+
| `manager` | 2 | MANAGER + MEMBER endpoints |
|
|
109
|
+
| `member` | 1 | MEMBER endpoints only |
|
|
110
|
+
|
|
111
|
+
Multiple roles can share the same level (e.g., `editor: 2, manager: 2` → equivalent access).
|
|
112
|
+
|
|
113
|
+
### Custom Hierarchy
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// config.env.ts
|
|
117
|
+
multiTenancy: {
|
|
118
|
+
roleHierarchy: { viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 },
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// roles.ts — type-safe constants
|
|
122
|
+
import { createHierarchyRoles } from '@lenne.tech/nest-server';
|
|
123
|
+
export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 });
|
|
124
|
+
// HR.VIEWER = 'viewer', HR.EDITOR = 'editor', HR.MANAGER = 'manager', HR.ADMIN = 'admin', HR.OWNER = 'owner'
|
|
125
|
+
|
|
126
|
+
// resolver.ts
|
|
127
|
+
@Roles(HR.EDITOR) // requires level >= 2 (editor, manager, admin, owner all pass)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Normal (Non-Hierarchy) Roles
|
|
131
|
+
|
|
132
|
+
Roles not in `roleHierarchy` use exact match — no higher role can compensate:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// membership.role = 'auditor' → @Roles('auditor') passes, @Roles('manager') fails
|
|
136
|
+
@Roles('auditor')
|
|
137
|
+
async auditLog(@CurrentTenant() tenantId: string) { ... }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Tenant Context Rule
|
|
141
|
+
|
|
142
|
+
**When a tenant header is present:** Only `membership.role` is checked. `user.roles` is ignored (except ADMIN bypass).
|
|
143
|
+
|
|
144
|
+
**When no tenant header:** `user.roles` is checked instead. Hierarchy roles use level comparison, normal roles use exact match.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// Example: user.roles=['manager'], tenant membership.role='member'
|
|
148
|
+
// @Roles(DefaultHR.MANAGER) with X-Tenant-Id header → 403 (member < manager)
|
|
149
|
+
// @Roles(DefaultHR.MANAGER) without header → 200 (user.roles manager(2) >= manager(2))
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Skipping Tenant Checks
|
|
153
|
+
|
|
154
|
+
Use `@SkipTenantCheck()` for endpoints that intentionally work without tenant context:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
@SkipTenantCheck()
|
|
158
|
+
@Roles(RoleEnum.S_USER)
|
|
159
|
+
async listMyTenants() { ... }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Note: `@SkipTenantCheck()` with hierarchy roles still checks `user.roles` (no tenant context).
|
|
163
|
+
|
|
164
|
+
### Admin Bypass
|
|
165
|
+
|
|
166
|
+
System admins (`RoleEnum.ADMIN`) bypass the membership check by default.
|
|
167
|
+
Disable with `multiTenancy: { adminBypass: false }`.
|
|
168
|
+
|
|
169
|
+
### Filtering Without Header
|
|
170
|
+
|
|
171
|
+
| User State | Filter Applied |
|
|
172
|
+
| ------------------------------ | ------------------------------------------------------------------- |
|
|
173
|
+
| Not authenticated, no context | Safety Net: `ForbiddenException` on tenantId-schemas |
|
|
174
|
+
| Authenticated, no memberships | Safety Net: `ForbiddenException` on tenantId-schemas |
|
|
175
|
+
| Authenticated, has memberships | `{ tenantId: { $in: [user's tenant IDs] } }` |
|
|
176
|
+
| Authenticated + hierarchy role | `{ tenantId: { $in: [qualified tenant IDs] } }` (filtered by level) |
|
|
177
|
+
| Admin without header | No filter (sees all data via `isAdminBypass`) |
|
|
178
|
+
|
|
179
|
+
## Extending via Module Inheritance
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
@Injectable()
|
|
183
|
+
export class TenantService extends CoreTenantService {
|
|
184
|
+
override async addMember(tenantId: string, userId: string, role?: string) {
|
|
185
|
+
const member = await super.addMember(tenantId, userId, role);
|
|
186
|
+
await this.notificationService.sendInvite(userId, tenantId);
|
|
187
|
+
return member;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// module
|
|
192
|
+
CoreTenantModule.forRoot({ service: TenantService });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Performance Considerations
|
|
196
|
+
|
|
197
|
+
The `CoreTenantGuard` resolves tenant memberships (`resolveUserTenantIds()`) on every authenticated request that does not include an `X-Tenant-Id` header. This is necessary so the Mongoose plugin can filter by `{ tenantId: { $in: tenantIds } }`.
|
|
198
|
+
|
|
199
|
+
For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup.
|
|
200
|
+
|
|
201
|
+
## Security Notes
|
|
202
|
+
|
|
203
|
+
> **Defense-in-Depth:** The Mongoose tenant plugin uses `tenantId` from `RequestContext`, which is only set by `CoreTenantGuard` after successful membership validation. The raw `X-Tenant-Id` header is **never** used directly for filtering. Even if the guard is bypassed (e.g., via `@SkipTenantCheck()`), the plugin's Safety Net throws `ForbiddenException` when a tenantId-scoped schema is accessed without valid tenant context.
|
|
204
|
+
|
|
205
|
+
> **Explicit tenantId on writes:** The plugin only auto-sets `tenantId` on new documents when no explicit value is provided. Service-layer code that accepts user-supplied `tenantId` as a creation parameter could allow cross-tenant writes. Never pass user-supplied `tenantId` directly to `create()` or `new Model()`.
|
|
206
|
+
|
|
207
|
+
### Secured Membership Controller Example
|
|
208
|
+
|
|
209
|
+
When building a tenant management UI, protect membership endpoints:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
@Controller('tenants/:tenantId/members')
|
|
213
|
+
export class TenantMemberController {
|
|
214
|
+
@Get()
|
|
215
|
+
@Roles(DefaultHR.MANAGER)
|
|
216
|
+
async listMembers(@CurrentTenant() tenantId: string) {
|
|
217
|
+
return this.tenantService.findMemberships(tenantId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@Post()
|
|
221
|
+
@Roles(DefaultHR.OWNER)
|
|
222
|
+
async addMember(@CurrentTenant() tenantId: string, @Body() body: AddMemberDto) {
|
|
223
|
+
return this.tenantService.addMember(tenantId, body.userId, body.role);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Related
|
|
229
|
+
|
|
230
|
+
- [Integration Checklist](./INTEGRATION-CHECKLIST.md)
|
|
231
|
+
- [Configurable Features](../../../.claude/rules/configurable-features.md)
|
|
232
|
+
- [Request Lifecycle](../../../docs/REQUEST-LIFECYCLE.md)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { ObjectType } from '@nestjs/graphql';
|
|
3
|
+
import { Schema } from '@nestjs/mongoose';
|
|
4
|
+
|
|
5
|
+
import { Restricted } from '../../common/decorators/restricted.decorator';
|
|
6
|
+
import { UnifiedField } from '../../common/decorators/unified-field.decorator';
|
|
7
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
8
|
+
import { CorePersistenceModel } from '../../common/models/core-persistence.model';
|
|
9
|
+
import { RequestContext } from '../../common/services/request-context.service';
|
|
10
|
+
import { DefaultHR, TenantMemberStatus } from './core-tenant.enums';
|
|
11
|
+
import { checkRoleAccess } from './core-tenant.helpers';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Core tenant member model (join table: User <-> Tenant).
|
|
15
|
+
*
|
|
16
|
+
* Represents a user's membership in a tenant with a specific role and status.
|
|
17
|
+
* This model is automatically excluded from tenant filtering (it is tenant-spanning).
|
|
18
|
+
*
|
|
19
|
+
* Projects can extend this model to add custom fields.
|
|
20
|
+
*/
|
|
21
|
+
@ObjectType({ description: 'Tenant membership', isAbstract: true })
|
|
22
|
+
@Restricted(RoleEnum.S_USER)
|
|
23
|
+
@Schema({ timestamps: true })
|
|
24
|
+
export class CoreTenantMemberModel extends CorePersistenceModel {
|
|
25
|
+
/**
|
|
26
|
+
* ID of the user who invited this member
|
|
27
|
+
*/
|
|
28
|
+
@UnifiedField({
|
|
29
|
+
description: 'ID of the inviting user',
|
|
30
|
+
isOptional: true,
|
|
31
|
+
mongoose: { type: String },
|
|
32
|
+
roles: RoleEnum.S_USER,
|
|
33
|
+
})
|
|
34
|
+
invitedBy: string = undefined;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Date when the user joined the tenant
|
|
38
|
+
*/
|
|
39
|
+
@UnifiedField({
|
|
40
|
+
description: 'Date when the user joined',
|
|
41
|
+
isOptional: true,
|
|
42
|
+
mongoose: { type: Date },
|
|
43
|
+
roles: RoleEnum.S_USER,
|
|
44
|
+
type: Date,
|
|
45
|
+
})
|
|
46
|
+
joinedAt: Date = undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Role within the tenant (string matching a key in the configured role hierarchy)
|
|
50
|
+
*/
|
|
51
|
+
@UnifiedField({
|
|
52
|
+
description: 'Tenant role',
|
|
53
|
+
mongoose: { default: 'member', type: String },
|
|
54
|
+
roles: RoleEnum.S_USER,
|
|
55
|
+
type: () => String,
|
|
56
|
+
})
|
|
57
|
+
role: string = undefined;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Membership status
|
|
61
|
+
*/
|
|
62
|
+
@UnifiedField({
|
|
63
|
+
description: 'Membership status',
|
|
64
|
+
mongoose: { default: TenantMemberStatus.ACTIVE, enum: Object.values(TenantMemberStatus), type: String },
|
|
65
|
+
roles: RoleEnum.S_USER,
|
|
66
|
+
type: () => String,
|
|
67
|
+
})
|
|
68
|
+
status: TenantMemberStatus = undefined;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tenant ID (= tenantId for data isolation)
|
|
72
|
+
*/
|
|
73
|
+
@UnifiedField({
|
|
74
|
+
description: 'Tenant ID',
|
|
75
|
+
mongoose: { index: true, type: String },
|
|
76
|
+
roles: RoleEnum.S_USER,
|
|
77
|
+
})
|
|
78
|
+
tenant: string = undefined;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* User ID (reference to User collection)
|
|
82
|
+
*/
|
|
83
|
+
@UnifiedField({
|
|
84
|
+
description: 'User ID',
|
|
85
|
+
mongoose: { index: true, type: String },
|
|
86
|
+
roles: RoleEnum.S_USER,
|
|
87
|
+
})
|
|
88
|
+
user: string = undefined;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Verification of the user's rights to access the properties of this object.
|
|
92
|
+
*
|
|
93
|
+
* Allows access when:
|
|
94
|
+
* - force mode is enabled
|
|
95
|
+
* - the requesting user owns this membership (user.id === this.user)
|
|
96
|
+
* - the requesting user is a system admin
|
|
97
|
+
* - the requesting user is at least a manager-level member of the same tenant
|
|
98
|
+
*/
|
|
99
|
+
override securityCheck(user: any, force?: boolean): this {
|
|
100
|
+
if (force) return this;
|
|
101
|
+
if (!user) throw new UnauthorizedException('Access to tenant membership denied');
|
|
102
|
+
|
|
103
|
+
// Own membership or system admin
|
|
104
|
+
if (user.id === this.user || user.hasRole?.(RoleEnum.ADMIN)) return this;
|
|
105
|
+
|
|
106
|
+
// Tenant manager/owner of the same tenant can view members.
|
|
107
|
+
// context.tenantId is the guard-validated tenant ID (not the raw header),
|
|
108
|
+
// see RequestContextMiddleware which reads req.tenantId.
|
|
109
|
+
const context = RequestContext.get();
|
|
110
|
+
const tenantRole = context?.tenantRole;
|
|
111
|
+
if (
|
|
112
|
+
tenantRole &&
|
|
113
|
+
checkRoleAccess([DefaultHR.MANAGER], undefined, tenantRole) &&
|
|
114
|
+
context?.tenantId === this.tenant
|
|
115
|
+
) {
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new UnauthorizedException('Access to tenant membership denied');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createParamDecorator, SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { RequestContext } from '../../common/services/request-context.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Metadata key for @SkipTenantCheck() decorator.
|
|
7
|
+
*/
|
|
8
|
+
export const SKIP_TENANT_CHECK_KEY = 'skipTenantCheck';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Method/class decorator that opts out of tenant checks for a specific endpoint.
|
|
12
|
+
* When present, CoreTenantGuard skips all tenant validation and does not set
|
|
13
|
+
* tenantId or isAdminBypass on the request.
|
|
14
|
+
*
|
|
15
|
+
* Use this for endpoints that intentionally work without tenant context,
|
|
16
|
+
* e.g., listing available tenants, user profile endpoints, etc.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* @SkipTenantCheck()
|
|
21
|
+
* @Roles(RoleEnum.S_USER)
|
|
22
|
+
* async listMyTenants() { ... }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export const SkipTenantCheck = () => SetMetadata(SKIP_TENANT_CHECK_KEY, true);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parameter decorator that extracts the validated tenant ID from the current request.
|
|
29
|
+
* Returns `undefined` if no tenant header is set or the endpoint has @SkipTenantCheck().
|
|
30
|
+
*
|
|
31
|
+
* Reads from RequestContext (set by CoreTenantGuard → req.tenantId →
|
|
32
|
+
* RequestContextMiddleware lazy getter → context.tenantId), so it works
|
|
33
|
+
* consistently across HTTP and GraphQL without context-type switching.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* @Get('projects')
|
|
38
|
+
* @Roles(DefaultHR.MEMBER)
|
|
39
|
+
* async listProjects(@CurrentTenant() tenantId: string | undefined) {
|
|
40
|
+
* // tenantId comes from X-Tenant-Id header, validated by CoreTenantGuard
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export const CurrentTenant = createParamDecorator((): string | undefined => {
|
|
45
|
+
return RequestContext.get()?.tenantId;
|
|
46
|
+
});
|