@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
|
@@ -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,268 @@
|
|
|
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
|
+
cacheTtlMs: 30000, // Membership cache TTL in ms (default: 30s, 0 = disabled)
|
|
47
|
+
roleHierarchy: { // Custom role hierarchy (default below)
|
|
48
|
+
member: 1,
|
|
49
|
+
manager: 2,
|
|
50
|
+
owner: 3,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Pre-configured but disabled
|
|
55
|
+
multiTenancy: { enabled: false },
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Components
|
|
59
|
+
|
|
60
|
+
| Component | Purpose |
|
|
61
|
+
| ------------------------ | ------------------------------------------------------ |
|
|
62
|
+
| `CoreTenantMemberModel` | User-tenant membership (join table with role + status) |
|
|
63
|
+
| `CoreTenantGuard` | APP_GUARD validating tenant header + membership |
|
|
64
|
+
| `CoreTenantService` | Membership CRUD (add, remove, update role, find) |
|
|
65
|
+
| `@SkipTenantCheck()` | Method/class decorator to skip tenant validation |
|
|
66
|
+
| `@CurrentTenant()` | Parameter decorator extracting validated tenant ID |
|
|
67
|
+
| `TenantMemberStatus` | Enum: ACTIVE, INVITED, SUSPENDED |
|
|
68
|
+
| `DefaultHR` | Type-safe constants for default hierarchy roles |
|
|
69
|
+
| `createHierarchyRoles()` | Generate type-safe constants from custom hierarchy |
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
### Protecting Endpoints with Hierarchy Roles
|
|
74
|
+
|
|
75
|
+
Use `@Roles()` with hierarchy role strings. Higher levels include lower levels (level comparison).
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { Roles, CurrentTenant, DefaultHR } from '@lenne.tech/nest-server';
|
|
79
|
+
|
|
80
|
+
@Controller('projects')
|
|
81
|
+
export class ProjectController {
|
|
82
|
+
@Get()
|
|
83
|
+
@Roles(DefaultHR.MEMBER) // any active member (level >= 1)
|
|
84
|
+
async list(@CurrentTenant() tenantId: string) {
|
|
85
|
+
return this.projectService.find({ tenantId });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Put(':id')
|
|
89
|
+
@Roles(DefaultHR.MANAGER) // at least manager level (level >= 2)
|
|
90
|
+
async update(@Param('id') id: string, @Body() body: UpdateDto) {
|
|
91
|
+
return this.projectService.update(id, body);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Delete(':id')
|
|
95
|
+
@Roles(DefaultHR.OWNER) // highest level only (level >= 3)
|
|
96
|
+
async delete(@Param('id') id: string) {
|
|
97
|
+
return this.projectService.delete(id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Hierarchy Roles
|
|
103
|
+
|
|
104
|
+
The role hierarchy defines levels where higher includes lower:
|
|
105
|
+
|
|
106
|
+
| Role Key | Level | Can Access |
|
|
107
|
+
| --------- | ----- | -------------------------- |
|
|
108
|
+
| `owner` | 3 | Everything |
|
|
109
|
+
| `manager` | 2 | MANAGER + MEMBER endpoints |
|
|
110
|
+
| `member` | 1 | MEMBER endpoints only |
|
|
111
|
+
|
|
112
|
+
Multiple roles can share the same level (e.g., `editor: 2, manager: 2` → equivalent access).
|
|
113
|
+
|
|
114
|
+
### Custom Hierarchy
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// config.env.ts
|
|
118
|
+
multiTenancy: {
|
|
119
|
+
roleHierarchy: { viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 },
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// roles.ts — type-safe constants
|
|
123
|
+
import { createHierarchyRoles } from '@lenne.tech/nest-server';
|
|
124
|
+
export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 });
|
|
125
|
+
// HR.VIEWER = 'viewer', HR.EDITOR = 'editor', HR.MANAGER = 'manager', HR.ADMIN = 'admin', HR.OWNER = 'owner'
|
|
126
|
+
|
|
127
|
+
// resolver.ts
|
|
128
|
+
@Roles(HR.EDITOR) // requires level >= 2 (editor, manager, admin, owner all pass)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Normal (Non-Hierarchy) Roles
|
|
132
|
+
|
|
133
|
+
Roles not in `roleHierarchy` use exact match — no higher role can compensate:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// membership.role = 'auditor' → @Roles('auditor') passes, @Roles('manager') fails
|
|
137
|
+
@Roles('auditor')
|
|
138
|
+
async auditLog(@CurrentTenant() tenantId: string) { ... }
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Tenant Context Rule
|
|
142
|
+
|
|
143
|
+
**When a tenant header is present:** Only `membership.role` is checked. `user.roles` is ignored (except ADMIN bypass).
|
|
144
|
+
|
|
145
|
+
**When no tenant header:** `user.roles` is checked instead. Hierarchy roles use level comparison, normal roles use exact match.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// Example: user.roles=['manager'], tenant membership.role='member'
|
|
149
|
+
// @Roles(DefaultHR.MANAGER) with X-Tenant-Id header → 403 (member < manager)
|
|
150
|
+
// @Roles(DefaultHR.MANAGER) without header → 200 (user.roles manager(2) >= manager(2))
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Skipping Tenant Checks
|
|
154
|
+
|
|
155
|
+
Use `@SkipTenantCheck()` for endpoints that intentionally work without tenant context:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
@SkipTenantCheck()
|
|
159
|
+
@Roles(RoleEnum.S_USER)
|
|
160
|
+
async listMyTenants() { ... }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Note: `@SkipTenantCheck()` with hierarchy roles still checks `user.roles` (no tenant context).
|
|
164
|
+
|
|
165
|
+
### Admin Bypass
|
|
166
|
+
|
|
167
|
+
System admins (`RoleEnum.ADMIN`) bypass the membership check by default.
|
|
168
|
+
Disable with `multiTenancy: { adminBypass: false }`.
|
|
169
|
+
|
|
170
|
+
### Filtering Without Header
|
|
171
|
+
|
|
172
|
+
| User State | Filter Applied |
|
|
173
|
+
| ------------------------------ | ------------------------------------------------------------------- |
|
|
174
|
+
| Not authenticated, no context | Safety Net: `ForbiddenException` on tenantId-schemas |
|
|
175
|
+
| Authenticated, no memberships | Safety Net: `ForbiddenException` on tenantId-schemas |
|
|
176
|
+
| Authenticated, has memberships | `{ tenantId: { $in: [user's tenant IDs] } }` |
|
|
177
|
+
| Authenticated + hierarchy role | `{ tenantId: { $in: [qualified tenant IDs] } }` (filtered by level) |
|
|
178
|
+
| Admin without header | No filter (sees all data via `isAdminBypass`) |
|
|
179
|
+
|
|
180
|
+
## Extending via Module Inheritance
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
@Injectable()
|
|
184
|
+
export class TenantService extends CoreTenantService {
|
|
185
|
+
override async addMember(tenantId: string, userId: string, role?: string) {
|
|
186
|
+
const member = await super.addMember(tenantId, userId, role);
|
|
187
|
+
await this.notificationService.sendInvite(userId, tenantId);
|
|
188
|
+
return member;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// module
|
|
193
|
+
CoreTenantModule.forRoot({ service: TenantService });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Performance Considerations
|
|
197
|
+
|
|
198
|
+
### Membership Cache (since 11.21.1)
|
|
199
|
+
|
|
200
|
+
The `CoreTenantGuard` uses an in-memory TTL cache for membership lookups and tenant ID resolution. This avoids repeated DB queries when the same user accesses the same tenant across multiple requests.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// config.env.ts — configure or disable the cache
|
|
204
|
+
multiTenancy: {
|
|
205
|
+
cacheTtlMs: 30000, // default: 30s. Set to 0 to disable.
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Cache behavior:**
|
|
210
|
+
- **Positive-only:** Only active memberships are cached. Non-member lookups always hit the DB (security-first).
|
|
211
|
+
- **Auto-invalidation:** `CoreTenantService.addMember/removeMember/updateMemberRole` automatically invalidate the cache.
|
|
212
|
+
- **Config-change detection:** Cache is flushed when `multiTenancy` config changes (e.g., `roleHierarchy` update).
|
|
213
|
+
- **Bounded:** Max 500 entries with FIFO eviction. Memory overhead: ~100-250 KB.
|
|
214
|
+
|
|
215
|
+
**Important:** The cache is process-local. In horizontally scaled deployments (multiple instances), membership changes on one instance are not reflected on other instances until the TTL expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
|
|
216
|
+
|
|
217
|
+
### Manual Cache Invalidation
|
|
218
|
+
|
|
219
|
+
When extending `CoreTenantService` with custom membership mutation methods, call `invalidateUser()` after changes:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
@Injectable()
|
|
223
|
+
export class TenantService extends CoreTenantService {
|
|
224
|
+
async customMembershipChange(tenantId: string, userId: string) {
|
|
225
|
+
// ... your logic ...
|
|
226
|
+
this.tenantGuard?.invalidateUser(userId);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Use `invalidateAll()` to flush the entire cache (e.g., after bulk operations).
|
|
232
|
+
|
|
233
|
+
### SkipTenantCheck
|
|
234
|
+
|
|
235
|
+
For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
|
|
236
|
+
|
|
237
|
+
## Security Notes
|
|
238
|
+
|
|
239
|
+
> **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.
|
|
240
|
+
|
|
241
|
+
> **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()`.
|
|
242
|
+
|
|
243
|
+
### Secured Membership Controller Example
|
|
244
|
+
|
|
245
|
+
When building a tenant management UI, protect membership endpoints:
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
@Controller('tenants/:tenantId/members')
|
|
249
|
+
export class TenantMemberController {
|
|
250
|
+
@Get()
|
|
251
|
+
@Roles(DefaultHR.MANAGER)
|
|
252
|
+
async listMembers(@CurrentTenant() tenantId: string) {
|
|
253
|
+
return this.tenantService.findMemberships(tenantId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@Post()
|
|
257
|
+
@Roles(DefaultHR.OWNER)
|
|
258
|
+
async addMember(@CurrentTenant() tenantId: string, @Body() body: AddMemberDto) {
|
|
259
|
+
return this.tenantService.addMember(tenantId, body.userId, body.role);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Related
|
|
265
|
+
|
|
266
|
+
- [Integration Checklist](./INTEGRATION-CHECKLIST.md)
|
|
267
|
+
- [Configurable Features](../../../.claude/rules/configurable-features.md)
|
|
268
|
+
- [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
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection token for the TenantMember Mongoose model.
|
|
3
|
+
* Use this constant instead of the string literal 'TenantMember' in @InjectModel() and getModelToken().
|
|
4
|
+
*/
|
|
5
|
+
export const TENANT_MEMBER_MODEL_TOKEN = 'TenantMember';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Membership status for tenant members.
|
|
9
|
+
*/
|
|
10
|
+
export enum TenantMemberStatus {
|
|
11
|
+
ACTIVE = 'ACTIVE',
|
|
12
|
+
/** Reserved for future invitation workflow (not yet used in core logic) */
|
|
13
|
+
INVITED = 'INVITED',
|
|
14
|
+
SUSPENDED = 'SUSPENDED',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default role hierarchy for tenant membership.
|
|
19
|
+
* Keys are role names (stored in membership documents), values are numeric levels.
|
|
20
|
+
* Higher value = more privileges. Multiple roles can share the same level.
|
|
21
|
+
*
|
|
22
|
+
* Can be customized via `multiTenancy.roleHierarchy` in config.
|
|
23
|
+
*
|
|
24
|
+
* Hierarchy roles use level comparison: a higher level includes all lower levels.
|
|
25
|
+
* Non-hierarchy roles (not in this config) use exact match only.
|
|
26
|
+
*
|
|
27
|
+
* @example Custom hierarchy:
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const roleHierarchy = { viewer: 1, editor: 2, manager: 2, owner: 3 };
|
|
30
|
+
* const HR = createHierarchyRoles(roleHierarchy);
|
|
31
|
+
* // HR.VIEWER = 'viewer', HR.EDITOR = 'editor', HR.MANAGER = 'manager', HR.OWNER = 'owner'
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_ROLE_HIERARCHY: Record<string, number> = {
|
|
35
|
+
member: 1,
|
|
36
|
+
manager: 2,
|
|
37
|
+
owner: 3,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate typed UPPER_CASE constants from a role hierarchy config.
|
|
42
|
+
* Provides type-safe role strings for use with @Roles() and @Restricted() decorators.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* const hierarchy = { viewer: 1, editor: 2, manager: 2, owner: 3 };
|
|
47
|
+
* const HR = createHierarchyRoles(hierarchy);
|
|
48
|
+
* // HR.VIEWER = 'viewer', HR.EDITOR = 'editor', HR.MANAGER = 'manager', HR.OWNER = 'owner'
|
|
49
|
+
*
|
|
50
|
+
* @Roles(HR.EDITOR) // requires at least level 2
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @returns Object with UPPER_CASE keys mapped to the original lowercase role name strings.
|
|
54
|
+
* E.g. `{ viewer: 1, owner: 3 }` → `{ VIEWER: 'viewer', OWNER: 'owner' }`.
|
|
55
|
+
*/
|
|
56
|
+
export function createHierarchyRoles<T extends Record<string, number>>(
|
|
57
|
+
hierarchy: T,
|
|
58
|
+
): { [K in keyof T as Uppercase<string & K>]: string & K } {
|
|
59
|
+
const result = {} as any;
|
|
60
|
+
for (const key of Object.keys(hierarchy)) {
|
|
61
|
+
result[key.toUpperCase()] = key;
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Type-safe constants for the default role hierarchy.
|
|
68
|
+
* Convenience export for projects using the default { member: 1, manager: 2, owner: 3 } hierarchy.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* @Roles(DefaultHR.MEMBER) // any active member (level >= 1)
|
|
73
|
+
* @Roles(DefaultHR.MANAGER) // at least manager level (level >= 2)
|
|
74
|
+
* @Roles(DefaultHR.OWNER) // highest level only (level >= 3)
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export const DefaultHR = createHierarchyRoles(DEFAULT_ROLE_HIERARCHY);
|