@lenne.tech/nest-server 11.34.0 → 11.35.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.
Files changed (151) hide show
  1. package/.claude/rules/architecture.md +3 -1
  2. package/.claude/rules/configurable-features.md +3 -3
  3. package/.claude/rules/role-system.md +281 -0
  4. package/CLAUDE.md +11 -1
  5. package/FRAMEWORK-API.md +7 -3
  6. package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
  7. package/dist/core/common/decorators/nested-type.registry.js +17 -0
  8. package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
  9. package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
  10. package/dist/core/common/decorators/restricted.decorator.js +52 -16
  11. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  12. package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
  13. package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
  14. package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
  15. package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
  16. package/dist/core/common/decorators/unified-field.decorator.js +6 -3
  17. package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
  18. package/dist/core/common/enums/role.enum.d.ts +8 -0
  19. package/dist/core/common/enums/role.enum.js +28 -1
  20. package/dist/core/common/enums/role.enum.js.map +1 -1
  21. package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
  22. package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
  23. package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
  24. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
  25. package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
  26. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
  27. package/dist/core/common/helpers/gridfs.helper.js +1 -1
  28. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  29. package/dist/core/common/helpers/validation-message.helper.d.ts +3 -0
  30. package/dist/core/common/helpers/validation-message.helper.js +41 -0
  31. package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
  32. package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
  33. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  34. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  35. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  36. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  37. package/dist/core/common/pipes/map-and-validate.pipe.js +16 -6
  38. package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
  39. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  40. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  41. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  42. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  43. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  44. package/dist/core/common/services/core-s3.service.js +4 -1
  45. package/dist/core/common/services/core-s3.service.js.map +1 -1
  46. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  47. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  48. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  49. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  50. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  51. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  52. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  53. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  54. package/dist/core/modules/file/core-file.service.d.ts +3 -1
  55. package/dist/core/modules/file/core-file.service.js +62 -7
  56. package/dist/core/modules/file/core-file.service.js.map +1 -1
  57. package/dist/core/modules/file/file-access.helper.d.ts +18 -0
  58. package/dist/core/modules/file/file-access.helper.js +46 -0
  59. package/dist/core/modules/file/file-access.helper.js.map +1 -0
  60. package/dist/core/modules/file/file-metadata.helper.js +1 -1
  61. package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
  62. package/dist/core/modules/file/file-roles.config.d.ts +10 -0
  63. package/dist/core/modules/file/file-roles.config.js +78 -0
  64. package/dist/core/modules/file/file-roles.config.js.map +1 -0
  65. package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
  66. package/dist/core/modules/file/file-roles.helper.js +8 -20
  67. package/dist/core/modules/file/file-roles.helper.js.map +1 -1
  68. package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
  69. package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
  70. package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
  71. package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
  72. package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
  73. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  74. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
  75. package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
  76. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  77. package/dist/core/modules/tenant/core-tenant.module.js +5 -0
  78. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  79. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  80. package/dist/core/modules/tenant/core-tenant.service.js +25 -0
  81. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  82. package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
  83. package/dist/core/modules/tus/core-tus.service.js +34 -4
  84. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  85. package/dist/core/modules/tus/index.d.ts +1 -0
  86. package/dist/core/modules/tus/index.js +1 -0
  87. package/dist/core/modules/tus/index.js.map +1 -1
  88. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  89. package/dist/core/modules/tus/tus.constants.js +2 -1
  90. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  91. package/dist/core/modules/user/core-user.service.js +5 -0
  92. package/dist/core/modules/user/core-user.service.js.map +1 -1
  93. package/dist/core/modules/user/inputs/core-user.input.js +8 -0
  94. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  95. package/dist/core.module.js +10 -0
  96. package/dist/core.module.js.map +1 -1
  97. package/dist/index.d.ts +3 -0
  98. package/dist/index.js +3 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/server/modules/file/file.service.js +4 -1
  101. package/dist/server/modules/file/file.service.js.map +1 -1
  102. package/dist/test/test.helper.d.ts +1 -0
  103. package/dist/test/test.helper.js +26 -6
  104. package/dist/test/test.helper.js.map +1 -1
  105. package/dist/tsconfig.build.tsbuildinfo +1 -1
  106. package/docs/REQUEST-LIFECYCLE.md +60 -1
  107. package/migration-guides/11.34.0-to-11.34.1.md +132 -0
  108. package/migration-guides/11.34.x-to-11.35.x.md +832 -0
  109. package/package.json +1 -1
  110. package/src/core/common/decorators/nested-type.registry.ts +42 -0
  111. package/src/core/common/decorators/restricted.decorator.ts +149 -21
  112. package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
  113. package/src/core/common/decorators/unified-field.decorator.ts +4 -3
  114. package/src/core/common/enums/role.enum.ts +116 -0
  115. package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
  116. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  117. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  118. package/src/core/common/helpers/validation-message.helper.ts +83 -0
  119. package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
  120. package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
  121. package/src/core/common/interfaces/server-options.interface.ts +139 -7
  122. package/src/core/common/pipes/map-and-validate.pipe.ts +25 -7
  123. package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
  124. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  125. package/src/core/common/services/core-s3.service.ts +9 -1
  126. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  127. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  128. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  129. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  130. package/src/core/modules/file/README.md +61 -0
  131. package/src/core/modules/file/core-file.service.ts +166 -9
  132. package/src/core/modules/file/file-access.helper.ts +149 -0
  133. package/src/core/modules/file/file-metadata.helper.ts +13 -5
  134. package/src/core/modules/file/file-roles.config.ts +202 -0
  135. package/src/core/modules/file/file-roles.helper.ts +11 -40
  136. package/src/core/modules/tenant/README.md +20 -1
  137. package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
  138. package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
  139. package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
  140. package/src/core/modules/tenant/core-tenant.module.ts +12 -0
  141. package/src/core/modules/tenant/core-tenant.service.ts +56 -0
  142. package/src/core/modules/tus/README.md +46 -7
  143. package/src/core/modules/tus/core-tus.service.ts +94 -5
  144. package/src/core/modules/tus/index.ts +1 -0
  145. package/src/core/modules/tus/tus.constants.ts +17 -1
  146. package/src/core/modules/user/core-user.service.ts +12 -0
  147. package/src/core/modules/user/inputs/core-user.input.ts +28 -2
  148. package/src/core.module.ts +62 -1
  149. package/src/index.ts +3 -0
  150. package/src/server/modules/file/file.service.ts +45 -2
  151. package/src/test/test.helper.ts +54 -11
@@ -0,0 +1,193 @@
1
+ import { BadRequestException } from '@nestjs/common';
2
+
3
+ import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../enums/role.enum';
4
+
5
+ /**
6
+ * Mongoose plugin that refuses to STORE system roles (`s_*`) in a `roles` array.
7
+ *
8
+ * **This answers a different question than `mongooseRoleGuardPlugin`, which is why it is a
9
+ * separate plugin:**
10
+ *
11
+ * | Plugin | Question | Configurable | On violation |
12
+ * |--------|----------|--------------|--------------|
13
+ * | `mongooseRoleGuardPlugin` | WHO may change roles? | yes (`security.mongooseRoleGuardPlugin`) | strips the change |
14
+ * | `mongooseSystemRolePlugin` | WHICH values may be stored at all? | **no** | throws |
15
+ *
16
+ * Mixing the two would be a mistake in both directions: an ADMIN is allowed to change roles but
17
+ * still must not be able to store `s_self`, and a bypass (`runWithBypassRoleGuard()` / `force:
18
+ * true`) grants authority over the *change*, never permission to write a value that is invalid by
19
+ * construction. So this check runs FIRST and runs ALWAYS — for admins, for bypassed writes, for
20
+ * system operations with no user context.
21
+ *
22
+ * **Why storing one is dangerous:** `hasRole()` is a plain string intersection
23
+ * (`core-user.model.ts`), so a stored `'s_self'` satisfies every `S_SELF` check — including
24
+ * `updateUser` / `deleteUser` on ARBITRARY users (mail/password change → account takeover) —
25
+ * without the account carrying any real role. The rule "NEVER store S_ roles in user.roles" was
26
+ * documented for years; this is where it became enforced.
27
+ *
28
+ * **Why it throws rather than stripping:** silently dropping the value would hide a
29
+ * misconfiguration in an admin tool, which is exactly how the dormant skeleton key gets minted in
30
+ * the first place. A loud 400 is recoverable; a silent success is not observable.
31
+ *
32
+ * **Why the check is broader than the six `RoleEnum.S_*` members:** it rejects ANY value that
33
+ * looks like a system role after trimming and lower-casing (see {@link looksLikeSystemRole}).
34
+ * The framework cannot distinguish a future system role from a project role that happens to start
35
+ * with `s_`, and a false rejection is fixable by renaming while a false acceptance is a silent
36
+ * authorization hole. Projects using an `s_`-prefixed role name must rename it — see the
37
+ * 11.34.x-to-11.35.x migration guide.
38
+ *
39
+ * Registered unconditionally in `CoreModule` — unlike the configurable plugins beside it, there is
40
+ * no opt-out, because there is no legitimate reason to store one of these values.
41
+ */
42
+ export function mongooseSystemRolePlugin(schema) {
43
+ schema.pre('save', function () {
44
+ if (!this.isModified('roles')) {
45
+ return;
46
+ }
47
+ // A NEW document has no stored baseline, so every value in it is being introduced.
48
+ assertNoSystemRoles(this['roles'], this.isNew ? [] : toRoleArray(this.$__.originalRoles));
49
+ });
50
+
51
+ // Capture the pre-modification value so the save hook above can tell an introduced value from a
52
+ // pre-existing one. `init` fires when a document is hydrated from the database.
53
+ schema.post('init', function () {
54
+ this.$__.originalRoles = Array.isArray(this['roles']) ? [...this['roles']] : undefined;
55
+ });
56
+
57
+ for (const op of ['findOneAndUpdate', 'updateOne', 'replaceOne', 'findOneAndReplace']) {
58
+ schema.pre(op, async function () {
59
+ await assertUpdateIntroducesNoSystemRoles(this);
60
+ });
61
+ }
62
+
63
+ // updateMany can span documents with different stored values, so there is no single baseline to
64
+ // compare against. Any system role in the update payload is refused outright.
65
+ schema.pre('updateMany', function () {
66
+ assertUpdateHasNoSystemRoles(this.getUpdate());
67
+ });
68
+
69
+ schema.pre('insertMany', function (docs) {
70
+ if (!Array.isArray(docs)) {
71
+ return;
72
+ }
73
+ for (const doc of docs) {
74
+ assertNoSystemRoles(doc?.roles);
75
+ }
76
+ });
77
+
78
+ schema.pre('bulkWrite', function (ops) {
79
+ if (!Array.isArray(ops)) {
80
+ return;
81
+ }
82
+ for (const op of ops) {
83
+ if ('insertOne' in op) {
84
+ assertNoSystemRoles(op.insertOne.document?.roles);
85
+ } else if ('updateOne' in op) {
86
+ assertUpdateHasNoSystemRoles(op.updateOne.update);
87
+ } else if ('updateMany' in op) {
88
+ assertUpdateHasNoSystemRoles(op.updateMany.update);
89
+ } else if ('replaceOne' in op) {
90
+ assertNoSystemRoles(op.replaceOne.replacement?.roles);
91
+ }
92
+ }
93
+ });
94
+ }
95
+
96
+ /** Normalize an unknown value to a string array; anything else becomes an empty baseline. */
97
+ function toRoleArray(value: unknown): string[] {
98
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
99
+ }
100
+
101
+ /**
102
+ * Throw if the write INTRODUCES a system role.
103
+ *
104
+ * `alreadyStored` is the document's current value. A system role that is already in there is NOT
105
+ * refused, and this is load-bearing rather than lenient: `CrudService.update()` writes the whole
106
+ * object back, so a login (`updateRefreshToken` → `update`) re-sends the stored `roles` verbatim.
107
+ * Refusing that would lock every already-contaminated account out of the system on upgrade — and
108
+ * would do the same, fleet-wide, to any project whose own role name happens to start with `s_`.
109
+ * An upgrade must not cause a login outage; cleaning up stored values is what the migration guide's
110
+ * audit query is for.
111
+ *
112
+ * Accepts anything: a non-array (nothing to check) and non-string elements (cannot be a system
113
+ * role) pass through untouched — type validation is not this plugin's job.
114
+ */
115
+ function assertNoSystemRoles(roles: unknown, alreadyStored: string[] = []): void {
116
+ if (!Array.isArray(roles)) {
117
+ return;
118
+ }
119
+
120
+ const introduced = roles.filter((role) => looksLikeSystemRole(role) && !alreadyStored.includes(role));
121
+ if (introduced.length) {
122
+ throw new BadRequestException(
123
+ `System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles: ${introduced.join(', ')}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Query-level variant: resolves the affected document's stored roles before deciding.
130
+ *
131
+ * The lookup is skipped entirely unless the payload actually carries a system role, so the common
132
+ * case costs nothing — only a write that is about to be refused pays for one extra read.
133
+ */
134
+ async function assertUpdateIntroducesNoSystemRoles(query: any): Promise<void> {
135
+ const update = query.getUpdate();
136
+ if (!update || !collectRoleValues(update).some((role) => looksLikeSystemRole(role))) {
137
+ return;
138
+ }
139
+
140
+ // Only now — the payload is suspicious, so the baseline is worth one read.
141
+ let stored: string[] = [];
142
+ try {
143
+ const current = await query.model.findOne(query.getFilter()).select('roles').lean().exec();
144
+ stored = toRoleArray(current?.roles);
145
+ } catch {
146
+ // A failed lookup must not turn into an allow: fall through with an empty baseline, which
147
+ // refuses the write. Failing closed is correct for an authorization invariant.
148
+ }
149
+
150
+ assertUpdateHasNoSystemRoles(update, stored);
151
+ }
152
+
153
+ /** Every roles value the update could write, flattened across the operators that can carry one. */
154
+ function collectRoleValues(update: any): unknown[] {
155
+ const values: unknown[] = [
156
+ ...toRoleArray(update.roles),
157
+ ...toRoleArray(update.$set?.roles),
158
+ ...toRoleArray(update.$setOnInsert?.roles),
159
+ ];
160
+
161
+ for (const operator of [update.$push?.roles, update.$addToSet?.roles]) {
162
+ if (operator === undefined || operator === null) {
163
+ continue;
164
+ }
165
+ values.push(...(Array.isArray(operator?.$each) ? operator.$each : [operator]));
166
+ }
167
+
168
+ return values;
169
+ }
170
+
171
+ /**
172
+ * Check every operator through which a roles value can reach the document.
173
+ *
174
+ * `$pull` is deliberately NOT checked — removing an `s_*` value from a document that already has
175
+ * one is exactly the cleanup this release asks projects to perform, so it must stay possible.
176
+ */
177
+ function assertUpdateHasNoSystemRoles(update: any, alreadyStored: string[] = []): void {
178
+ if (!update) {
179
+ return;
180
+ }
181
+
182
+ assertNoSystemRoles(update.roles, alreadyStored);
183
+ assertNoSystemRoles(update.$set?.roles, alreadyStored);
184
+ assertNoSystemRoles(update.$setOnInsert?.roles, alreadyStored);
185
+
186
+ // $push / $addToSet take either a bare value or a { $each: [...] } modifier
187
+ for (const operator of [update.$push?.roles, update.$addToSet?.roles]) {
188
+ if (operator === undefined || operator === null) {
189
+ continue;
190
+ }
191
+ assertNoSystemRoles(Array.isArray(operator?.$each) ? operator.$each : [operator], alreadyStored);
192
+ }
193
+ }
@@ -1,8 +1,48 @@
1
- import { ForbiddenException } from '@nestjs/common';
1
+ import { ForbiddenException, Logger } from '@nestjs/common';
2
2
 
3
3
  import { ConfigService } from '../services/config.service';
4
4
  import { RequestContext } from '../services/request-context.service';
5
5
 
6
+ /** Models already warned about, so the message appears once per process rather than per query. */
7
+ const isolationDisabledWarned = new Set<string>();
8
+
9
+ /**
10
+ * Say out loud that isolation is off for a schema that was built for it.
11
+ *
12
+ * `excludeSchemas` is a legitimate feature — a genuinely global lookup table has no tenant. But this
13
+ * plugin only ever attaches to schemas that DECLARE a `tenantId` field, so reaching here means the
14
+ * author of that schema intended per-tenant rows and the configuration silently overrides them.
15
+ * Nothing said so, and the shape is easy to arrive at by accident: the framework's own documentation
16
+ * carried `excludeSchemas: ['User', 'Session']` in a copyable `@example` from 11.20.0 onwards, which
17
+ * on a project with per-tenant users switches filtering off for the user collection.
18
+ *
19
+ * A warning, not a boot failure. An ambiguous ROLE vocabulary fails the boot because its access
20
+ * decisions cannot be resolved coherently; this configuration is perfectly coherent — it is simply
21
+ * one nobody may make without noticing.
22
+ *
23
+ * The membership model is skipped: `CoreModule` adds it to `excludeSchemas` itself because
24
+ * membership is tenant-spanning by design, so warning about it would be the framework complaining
25
+ * about its own correct default.
26
+ */
27
+ function warnIsolationDisabled(modelName: string): void {
28
+ if (isolationDisabledWarned.has(modelName)) {
29
+ return;
30
+ }
31
+ isolationDisabledWarned.add(modelName);
32
+
33
+ const membershipModel = ConfigService.configFastButReadOnly?.multiTenancy?.membershipModel ?? 'TenantMember';
34
+ if (modelName === membershipModel) {
35
+ return;
36
+ }
37
+
38
+ new Logger('mongooseTenantPlugin').warn(
39
+ `Tenant isolation is DISABLED for "${modelName}": the schema declares a tenantId field, but ` +
40
+ `"${modelName}" is listed in multiTenancy.excludeSchemas — so no tenant filter is applied and ` +
41
+ `every query on it sees every tenant's rows. Remove it from excludeSchemas unless this ` +
42
+ `collection is genuinely global.`,
43
+ );
44
+ }
45
+
6
46
  /**
7
47
  * Mongoose plugin that provides automatic tenant-based data isolation.
8
48
  * Only activates on schemas that have a `tenantId` path defined.
@@ -63,6 +103,19 @@ export function mongooseTenantPlugin(schema) {
63
103
  if (filter !== undefined) {
64
104
  this.where(filter);
65
105
  }
106
+
107
+ // The filter above constrains WHICH rows are touched, never what the update WRITES. So a
108
+ // caller could legitimately match their own row and then rewrite its tenantId — moving it
109
+ // into a tenant they control, or simply out of reach of its rightful owner.
110
+ const single = resolveSingleTenantId(modelName);
111
+ if (single) {
112
+ const update: any = typeof this.getUpdate === 'function' ? this.getUpdate() : undefined;
113
+ for (const candidate of [update?.tenantId, update?.$set?.tenantId, update?.$setOnInsert?.tenantId]) {
114
+ if (candidate !== undefined) {
115
+ assertOwnTenant(candidate, single, false);
116
+ }
117
+ }
118
+ }
66
119
  });
67
120
  }
68
121
 
@@ -70,14 +123,23 @@ export function mongooseTenantPlugin(schema) {
70
123
  // Intentional asymmetry: writes only set tenantId when truthy (not null).
71
124
  // Only uses single tenantId from header — tenantIds array is for reads only.
72
125
  schema.pre('save', function () {
126
+ // Document hooks: `this` is the document instance — modelName is on the constructor (the Model class)
127
+ const modelName = (this.constructor as any).modelName;
128
+ const tenantId = resolveSingleTenantId(modelName);
129
+ if (!tenantId) {
130
+ return;
131
+ }
132
+
73
133
  if (this.isNew && !this['tenantId']) {
74
- // Document hooks: `this` is the document instance — modelName is on the constructor (the Model class)
75
- const modelName = (this.constructor as any).modelName;
76
- const tenantId = resolveSingleTenantId(modelName);
77
- if (tenantId) {
78
- this['tenantId'] = tenantId;
79
- }
134
+ this['tenantId'] = tenantId;
135
+ return;
80
136
  }
137
+
138
+ // A tenantId that is already on the document did NOT come from this hook. Stamping only when
139
+ // absent is right for system writes, but on its own it means a caller-supplied value survives —
140
+ // so an explicit foreign tenantId would place the row in someone else's tenant, and modifying it
141
+ // on an existing document would move the row out of this one.
142
+ assertOwnTenant(this['tenantId'], tenantId, this.isNew);
81
143
  });
82
144
 
83
145
  // === insertMany (Mongoose 9: first arg is docs array, no next callback) ===
@@ -89,6 +151,8 @@ export function mongooseTenantPlugin(schema) {
89
151
  for (const doc of docs) {
90
152
  if (!doc.tenantId) {
91
153
  doc.tenantId = tenantId;
154
+ } else {
155
+ assertOwnTenant(doc.tenantId, tenantId, true);
92
156
  }
93
157
  }
94
158
  }
@@ -123,17 +187,182 @@ export function mongooseTenantPlugin(schema) {
123
187
  }
124
188
  });
125
189
 
126
- // === Aggregate: prepend $match stage ===
190
+ // === estimatedDocumentCount: unfilterable, so refuse it inside a tenant context ===
191
+ //
192
+ // It reads collection metadata rather than running a query, so MongoDB ignores any filter — there
193
+ // is nothing to inject. Returning it unchanged would hand a tenant the row count of the WHOLE
194
+ // collection, i.e. every other tenant's volume. Throwing is the only honest option; callers that
195
+ // want a tenant's own count use countDocuments(), which IS filtered.
196
+ schema.pre('estimatedDocumentCount', function () {
197
+ const modelName = (this as any).model?.modelName;
198
+ if (shouldBypass(modelName)) {
199
+ return;
200
+ }
201
+ const context = RequestContext.get();
202
+ if (context?.isAdminBypass && !context?.tenantId) {
203
+ return; // platform admin without a tenant header legitimately sees everything
204
+ }
205
+ throw new ForbiddenException(
206
+ "estimatedDocumentCount() cannot be tenant-filtered and would expose other tenants' row counts — " +
207
+ 'use countDocuments() instead',
208
+ );
209
+ });
210
+
211
+ // === Aggregate: prepend $match stage, and secure every cross-collection stage ===
127
212
  schema.pre('aggregate', function () {
128
213
  // Aggregate hooks: `this` is the Aggregation pipeline — the model is on the internal `_model` property
129
- const modelName = (this as any)._model?.modelName;
214
+ const model = (this as any)._model;
215
+ const modelName = model?.modelName;
130
216
  const filter = resolveTenantFilter(modelName);
131
- if (filter !== undefined) {
132
- this.pipeline().unshift({ $match: filter });
217
+ if (filter === undefined) {
218
+ return;
219
+ }
220
+
221
+ const pipeline = this.pipeline();
222
+
223
+ // $out and $merge are the only aggregation stages that WRITE, and neither can be constrained
224
+ // the way a read stage can: $out REPLACES a whole collection, and $merge writes rows whose
225
+ // tenantId comes from the pipeline rather than from the caller's context. Either one turns an
226
+ // aggregation into a way to launder rows across the boundary — or, with $out, to erase another
227
+ // tenant's collection outright. There is nothing to inject here, so a tenant-scoped caller is
228
+ // refused; system code that legitimately needs them runs under runWithBypassTenantGuard().
229
+ const writeStage = pipeline.find((stage: any) => stage && (stage.$out !== undefined || stage.$merge !== undefined));
230
+ if (writeStage) {
231
+ throw new ForbiddenException(
232
+ `Aggregation write stages ($out / $merge) are not permitted inside a tenant context — ` +
233
+ 'they cannot be tenant-filtered. Run them as a system operation if this is intended.',
234
+ );
133
235
  }
236
+
237
+ pipeline.unshift({ $match: filter });
238
+
239
+ // The $match above only constrains the SOURCE collection. `$lookup`, `$unionWith` and
240
+ // `$graphLookup` read a DIFFERENT collection, and that collection's own `aggregate` hook never
241
+ // fires — the join runs inside this pipeline. Without the pass below, a single aggregation
242
+ // returns every tenant's rows from the joined collection, which is exactly the shape a
243
+ // reporting query takes.
244
+ secureCrossCollectionStages(pipeline, model);
134
245
  });
135
246
  }
136
247
 
248
+ /**
249
+ * Inject the tenant filter into every stage that reads another collection.
250
+ *
251
+ * Recurses, because these stages nest: a `$lookup.pipeline` may itself contain a `$lookup`, and
252
+ * `$facet` holds a sub-pipeline per key.
253
+ *
254
+ * A joined collection is only constrained when it is itself tenant-scoped (its model has a
255
+ * `tenantId` path and is not excluded). Joining a global lookup table stays untouched — filtering
256
+ * it by `tenantId` would silently return nothing.
257
+ */
258
+ function secureCrossCollectionStages(pipeline: any[], model: any): void {
259
+ if (!Array.isArray(pipeline)) {
260
+ return;
261
+ }
262
+
263
+ for (const stage of pipeline) {
264
+ if (!stage || typeof stage !== 'object') {
265
+ continue;
266
+ }
267
+
268
+ if (stage.$lookup) {
269
+ const filter = filterForCollection(stage.$lookup.from, model);
270
+ if (filter) {
271
+ // MongoDB 5.0+ allows `pipeline` alongside localField/foreignField, so this works for the
272
+ // concise join form too, not only the explicit-pipeline one.
273
+ stage.$lookup.pipeline = [{ $match: filter }, ...(stage.$lookup.pipeline ?? [])];
274
+ }
275
+ secureCrossCollectionStages(stage.$lookup.pipeline, model);
276
+ }
277
+
278
+ if (stage.$unionWith) {
279
+ // Two forms: `$unionWith: 'coll'` and `$unionWith: { coll, pipeline }`.
280
+ if (typeof stage.$unionWith === 'string') {
281
+ const filter = filterForCollection(stage.$unionWith, model);
282
+ if (filter) {
283
+ stage.$unionWith = { coll: stage.$unionWith, pipeline: [{ $match: filter }] };
284
+ }
285
+ } else {
286
+ const filter = filterForCollection(stage.$unionWith.coll, model);
287
+ if (filter) {
288
+ stage.$unionWith.pipeline = [{ $match: filter }, ...(stage.$unionWith.pipeline ?? [])];
289
+ }
290
+ secureCrossCollectionStages(stage.$unionWith.pipeline, model);
291
+ }
292
+ }
293
+
294
+ if (stage.$graphLookup) {
295
+ const filter = filterForCollection(stage.$graphLookup.from, model);
296
+ if (filter) {
297
+ // $graphLookup takes no pipeline; `restrictSearchWithMatch` is its filter hook and applies
298
+ // to EVERY recursive step, which is what a traversal needs.
299
+ stage.$graphLookup.restrictSearchWithMatch = {
300
+ ...stage.$graphLookup.restrictSearchWithMatch,
301
+ ...filter,
302
+ };
303
+ }
304
+ }
305
+
306
+ if (stage.$facet && typeof stage.$facet === 'object') {
307
+ for (const branch of Object.values(stage.$facet)) {
308
+ secureCrossCollectionStages(branch as any[], model);
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Tenant filter for a JOINED collection, or `undefined` when none applies.
316
+ *
317
+ * Resolves the collection name back to its model so the same "is this tenant-scoped?" rule applies
318
+ * as for a direct query. An unknown collection name yields no filter: the plugin cannot tell
319
+ * whether it is tenant-scoped, and inventing a `tenantId` constraint for a collection that has no
320
+ * such field would turn a working join into an empty result.
321
+ */
322
+ function filterForCollection(collectionName: unknown, sourceModel: any): Record<string, any> | undefined {
323
+ if (typeof collectionName !== 'string' || !collectionName) {
324
+ return undefined;
325
+ }
326
+
327
+ const models = sourceModel?.db?.models ?? {};
328
+ for (const name of Object.keys(models)) {
329
+ const candidate = models[name];
330
+ if (candidate?.collection?.name !== collectionName) {
331
+ continue;
332
+ }
333
+ if (!candidate.schema?.path('tenantId')) {
334
+ return undefined; // not tenant-scoped — leave the join alone
335
+ }
336
+ const filter = resolveTenantFilter(name);
337
+ // `{}` means "admin bypass, sees everything" — nothing to inject.
338
+ return filter && Object.keys(filter).length ? filter : undefined;
339
+ }
340
+
341
+ return undefined;
342
+ }
343
+
344
+ /**
345
+ * Refuse a tenantId that is not the caller's own.
346
+ *
347
+ * Covers both directions of the same boundary:
348
+ * - creating a row with a FOREIGN tenantId → planting data in someone else's tenant;
349
+ * - changing an existing row's tenantId → moving data out of this one.
350
+ *
351
+ * Throwing rather than overwriting is deliberate. Silently rewriting the value would make a request
352
+ * that asked for something impossible look like it succeeded, and for tenant-scoped medical data
353
+ * "the write went somewhere other than you asked" is not a recoverable ambiguity.
354
+ */
355
+ function assertOwnTenant(value: unknown, ownTenantId: string, isNew: boolean): void {
356
+ if (value === undefined || value === null || value === ownTenantId) {
357
+ return;
358
+ }
359
+ throw new ForbiddenException(
360
+ isNew
361
+ ? `Cannot create a document in a foreign tenant (got "${String(value)}", own tenant is "${ownTenantId}")`
362
+ : `Cannot move a document to a foreign tenant (got "${String(value)}", own tenant is "${ownTenantId}")`,
363
+ );
364
+ }
365
+
137
366
  /**
138
367
  * Check common bypass conditions.
139
368
  *
@@ -146,7 +375,10 @@ function shouldBypass(modelName?: string): boolean {
146
375
  const context = RequestContext.get();
147
376
  if (!context) return true;
148
377
  if (context.bypassTenantGuard) return true;
149
- if (modelName && mtConfig.excludeSchemas?.includes(modelName)) return true;
378
+ if (modelName && mtConfig.excludeSchemas?.includes(modelName)) {
379
+ warnIsolationDisabled(modelName);
380
+ return true;
381
+ }
150
382
 
151
383
  return false;
152
384
  }
@@ -92,7 +92,15 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
92
92
  this.logger.warn('Ignoring the `s3` configuration: no `bucket` is set, so S3 cannot be used.');
93
93
  return;
94
94
  }
95
- const presigned = raw.presignedDownloads;
95
+ // `enabled: false` disables, exactly as everywhere else in the config (see
96
+ // .claude/rules/configurable-features.md). Reading any object as "enabled" made this the one
97
+ // knob where the documented pre-configuration idiom silently turned a feature ON — and it is a
98
+ // feature that hands out session-less bearer URLs.
99
+ const rawPresigned = raw.presignedDownloads;
100
+ const presigned =
101
+ rawPresigned && typeof rawPresigned === 'object' && (rawPresigned as { enabled?: boolean }).enabled === false
102
+ ? undefined
103
+ : rawPresigned;
96
104
  this.config = {
97
105
  accessKeyId: raw.accessKeyId,
98
106
  autoCreateBucket: raw.autoCreateBucket ?? false,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * How a non-HTTP transport asks the tenant machinery "which tenant is this caller in?".
3
+ *
4
+ * DELIBERATELY IMPORT-FREE apart from a type-only import, so it stays a leaf — and it lives under
5
+ * `src/core/common/` rather than next to the tenant module for a second reason: the reader is
6
+ * `graphql-ws-context.helper`, also in `src/core/common/`, and `src/core/common/**` must not import
7
+ * from `src/core/modules/**`. The writer (`CoreTenantGuard`) imports in the allowed direction.
8
+ * A registry rather than DI because the GraphQL wiring in `core.module.ts` must not depend on a
9
+ * provider that only exists when multi-tenancy is configured. Same pattern as
10
+ * `core-cron-jobs.registry.ts`. See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
11
+ *
12
+ * WHY IT EXISTS: `CoreTenantGuard` answers this question from an Express request and writes the
13
+ * answer onto it (`request.tenantId` / `request.tenantIds`), which `RequestContextMiddleware` then
14
+ * exposes through AsyncLocalStorage. A WebSocket never traverses either — no Express middleware runs
15
+ * on an upgrade, and the guard's own `getRequest()` returns nothing for a subscription context, so it
16
+ * returns `true` without deciding anything. This registry is what lets the WebSocket path ask the
17
+ * same question through the same membership logic and cache.
18
+ */
19
+ import type { IRequestContext } from './request-context.service';
20
+
21
+ /** The tenant-relevant slice of a request context — everything the Mongoose tenant plugin reads. */
22
+ export type ResolvedTenantContext = Pick<IRequestContext, 'isAdminBypass' | 'tenantId' | 'tenantIds' | 'tenantRole'>;
23
+
24
+ export interface TenantContextResolver {
25
+ /**
26
+ * @param user the authenticated user (with `roles`), or undefined for an anonymous caller
27
+ * @param headerTenantId the raw tenant id the transport carried, if any — NEVER trusted; it is
28
+ * validated against an active membership before it is returned
29
+ */
30
+ resolve(user: { id: string; roles?: string[] } | undefined, headerTenantId?: string): Promise<ResolvedTenantContext>;
31
+ }
32
+
33
+ let resolver: TenantContextResolver | undefined;
34
+
35
+ /** Registered by `CoreTenantGuard` on init. Idempotent — test fixtures build the guard repeatedly. */
36
+ export function setTenantContextResolver(next: TenantContextResolver | undefined): void {
37
+ resolver = next;
38
+ }
39
+
40
+ /**
41
+ * The registered resolver, or `undefined` when multi-tenancy is not in play.
42
+ *
43
+ * `undefined` must NOT be read as "no tenant restrictions": it means nothing can answer the
44
+ * question, so a caller establishes a context WITHOUT tenant information and lets the Mongoose
45
+ * plugin's safety net refuse tenant-scoped reads. That is the difference between "this data is not
46
+ * tenant-scoped" and "nobody knows which tenant this is".
47
+ */
48
+ export function getTenantContextResolver(): TenantContextResolver | undefined {
49
+ return resolver;
50
+ }
@@ -9,6 +9,8 @@ import {
9
9
  } from '@nestjs/common';
10
10
  import { ModuleRef, Reflector } from '@nestjs/core';
11
11
  import { GqlExecutionContext } from '@nestjs/graphql';
12
+
13
+ import { resolveGuardRequest } from '../../../common/helpers/execution-context-request.helper';
12
14
  import { firstValueFrom, isObservable } from 'rxjs';
13
15
 
14
16
  import { RoleEnum } from '../../../common/enums/role.enum';
@@ -341,10 +343,10 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
341
343
  * Integrate request from GraphQL
342
344
  */
343
345
  getRequest(context: ExecutionContext) {
344
- const ctx = GqlExecutionContext.create(context);
345
- // For GraphQL: ctx.getContext() is the GQL context with `.req` property
346
- // For REST/HTTP: ctx.getContext() returns the `next` function (truthy but without `.req`)
347
- // Using `?.req ||` ensures we fall back to the HTTP request for REST controllers
348
- return ctx.getContext()?.req || context.switchToHttp().getRequest();
346
+ // Shared with BetterAuthRolesGuard and CoreTenantGuard — see execution-context-request.helper.
347
+ // For GraphQL over HTTP the context carries `.req`; for REST it does not (it is the `next`
348
+ // function) and the HTTP fallback applies; for a GraphQL SUBSCRIPTION neither holds, and the
349
+ // subscription context itself is the request-like object.
350
+ return resolveGuardRequest(context, (ctx) => GqlExecutionContext.create(ctx).getContext());
349
351
  }
350
352
  }
@@ -8,6 +8,8 @@ import {
8
8
  } from '@nestjs/common';
9
9
  import { GqlExecutionContext } from '@nestjs/graphql';
10
10
 
11
+ import { resolveGuardRequest } from '../../common/helpers/execution-context-request.helper';
12
+
11
13
  import { RoleEnum } from '../../common/enums/role.enum';
12
14
  import { ErrorCode } from '../error-code';
13
15
  import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../tenant/core-tenant.helpers';
@@ -178,19 +180,11 @@ export class BetterAuthRolesGuard implements CanActivate {
178
180
  * Handles both GraphQL and HTTP contexts
179
181
  */
180
182
  private getRequest(context: ExecutionContext): any {
181
- // Try GraphQL context first
182
- try {
183
- const gqlContext = GqlExecutionContext.create(context);
184
- const ctx = gqlContext.getContext();
185
- if (ctx?.req) {
186
- return ctx.req;
187
- }
188
- } catch {
189
- // GraphQL context not available
190
- }
191
-
192
- // Fallback to HTTP context
193
- return context.switchToHttp().getRequest();
183
+ // Shared with RolesGuard and CoreTenantGuard — see execution-context-request.helper. The GraphQL
184
+ // WEBSOCKET branch is what makes `@Roles()` work on a subscription at all: the subscription
185
+ // context carries the authenticated user (CoreModule's onConnect records it), while `.req` does
186
+ // not exist there and the HTTP fallback yields the resolver root.
187
+ return resolveGuardRequest(context, (ctx) => GqlExecutionContext.create(ctx).getContext());
194
188
  }
195
189
 
196
190
  /**