@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.
Files changed (84) hide show
  1. package/README.md +444 -100
  2. package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
  3. package/dist/core/common/decorators/restricted.decorator.js +4 -1
  4. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  5. package/dist/core/common/helpers/input.helper.js +11 -8
  6. package/dist/core/common/helpers/input.helper.js.map +1 -1
  7. package/dist/core/common/interceptors/check-security.interceptor.js +10 -8
  8. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  9. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -1
  10. package/dist/core/common/middleware/request-context.middleware.js +10 -6
  11. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  12. package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
  13. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  14. package/dist/core/common/services/email.service.d.ts +5 -1
  15. package/dist/core/common/services/email.service.js +16 -2
  16. package/dist/core/common/services/email.service.js.map +1 -1
  17. package/dist/core/common/services/request-context.service.d.ts +3 -0
  18. package/dist/core/common/services/request-context.service.js +6 -0
  19. package/dist/core/common/services/request-context.service.js.map +1 -1
  20. package/dist/core/modules/auth/guards/roles.guard.js +6 -10
  21. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  22. package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
  23. package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
  24. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  25. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
  26. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
  27. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
  29. package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
  30. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  31. package/dist/core/modules/tenant/core-tenant-member.model.d.ts +11 -0
  32. package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
  33. package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
  34. package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
  35. package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
  36. package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
  37. package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
  38. package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
  39. package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
  40. package/dist/core/modules/tenant/core-tenant.guard.d.ts +25 -0
  41. package/dist/core/modules/tenant/core-tenant.guard.js +271 -0
  42. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
  43. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
  44. package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
  45. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
  46. package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
  47. package/dist/core/modules/tenant/core-tenant.module.js +58 -0
  48. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
  49. package/dist/core/modules/tenant/core-tenant.service.d.ts +19 -0
  50. package/dist/core/modules/tenant/core-tenant.service.js +170 -0
  51. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
  52. package/dist/core/modules/user/core-user.service.js +12 -1
  53. package/dist/core/modules/user/core-user.service.js.map +1 -1
  54. package/dist/core.module.js +11 -0
  55. package/dist/core.module.js.map +1 -1
  56. package/dist/index.d.ts +7 -0
  57. package/dist/index.js +7 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/tsconfig.build.tsbuildinfo +1 -1
  60. package/package.json +35 -24
  61. package/src/core/common/decorators/restricted.decorator.ts +12 -2
  62. package/src/core/common/helpers/input.helper.ts +24 -9
  63. package/src/core/common/interceptors/check-security.interceptor.ts +19 -13
  64. package/src/core/common/interfaces/server-options.interface.ts +80 -28
  65. package/src/core/common/middleware/request-context.middleware.ts +12 -5
  66. package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
  67. package/src/core/common/services/email.service.ts +26 -5
  68. package/src/core/common/services/request-context.service.ts +15 -1
  69. package/src/core/modules/auth/guards/roles.guard.ts +10 -10
  70. package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
  71. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
  72. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
  73. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +165 -0
  74. package/src/core/modules/tenant/README.md +268 -0
  75. package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
  76. package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
  77. package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
  78. package/src/core/modules/tenant/core-tenant.guard.ts +441 -0
  79. package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
  80. package/src/core/modules/tenant/core-tenant.module.ts +102 -0
  81. package/src/core/modules/tenant/core-tenant.service.ts +244 -0
  82. package/src/core/modules/user/core-user.service.ts +17 -1
  83. package/src/core.module.ts +15 -0
  84. 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);