@lenne.tech/nest-server 11.34.1 → 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 (143) 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/interceptors/check-security.interceptor.js +2 -1
  30. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  31. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  32. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  33. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  34. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  35. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  36. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  37. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  38. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  39. package/dist/core/common/services/core-s3.service.js +4 -1
  40. package/dist/core/common/services/core-s3.service.js.map +1 -1
  41. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  42. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  43. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  44. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  45. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  46. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  47. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  48. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  49. package/dist/core/modules/file/core-file.service.d.ts +3 -1
  50. package/dist/core/modules/file/core-file.service.js +62 -7
  51. package/dist/core/modules/file/core-file.service.js.map +1 -1
  52. package/dist/core/modules/file/file-access.helper.d.ts +18 -0
  53. package/dist/core/modules/file/file-access.helper.js +46 -0
  54. package/dist/core/modules/file/file-access.helper.js.map +1 -0
  55. package/dist/core/modules/file/file-metadata.helper.js +1 -1
  56. package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
  57. package/dist/core/modules/file/file-roles.config.d.ts +10 -0
  58. package/dist/core/modules/file/file-roles.config.js +78 -0
  59. package/dist/core/modules/file/file-roles.config.js.map +1 -0
  60. package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
  61. package/dist/core/modules/file/file-roles.helper.js +8 -20
  62. package/dist/core/modules/file/file-roles.helper.js.map +1 -1
  63. package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
  64. package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
  65. package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
  66. package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
  67. package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
  68. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  69. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
  70. package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
  71. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  72. package/dist/core/modules/tenant/core-tenant.module.js +5 -0
  73. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  74. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  75. package/dist/core/modules/tenant/core-tenant.service.js +25 -0
  76. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  77. package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
  78. package/dist/core/modules/tus/core-tus.service.js +34 -4
  79. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  80. package/dist/core/modules/tus/index.d.ts +1 -0
  81. package/dist/core/modules/tus/index.js +1 -0
  82. package/dist/core/modules/tus/index.js.map +1 -1
  83. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  84. package/dist/core/modules/tus/tus.constants.js +2 -1
  85. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  86. package/dist/core/modules/user/core-user.service.js +5 -0
  87. package/dist/core/modules/user/core-user.service.js.map +1 -1
  88. package/dist/core/modules/user/inputs/core-user.input.js +8 -0
  89. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  90. package/dist/core.module.js +10 -0
  91. package/dist/core.module.js.map +1 -1
  92. package/dist/index.d.ts +3 -0
  93. package/dist/index.js +3 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/server/modules/file/file.service.js +4 -1
  96. package/dist/server/modules/file/file.service.js.map +1 -1
  97. package/dist/test/test.helper.d.ts +1 -0
  98. package/dist/test/test.helper.js +26 -6
  99. package/dist/test/test.helper.js.map +1 -1
  100. package/dist/tsconfig.build.tsbuildinfo +1 -1
  101. package/docs/REQUEST-LIFECYCLE.md +60 -1
  102. package/migration-guides/11.34.x-to-11.35.x.md +832 -0
  103. package/package.json +1 -1
  104. package/src/core/common/decorators/nested-type.registry.ts +42 -0
  105. package/src/core/common/decorators/restricted.decorator.ts +149 -21
  106. package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
  107. package/src/core/common/decorators/unified-field.decorator.ts +4 -3
  108. package/src/core/common/enums/role.enum.ts +116 -0
  109. package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
  110. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  111. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  112. package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
  113. package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
  114. package/src/core/common/interfaces/server-options.interface.ts +139 -7
  115. package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
  116. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  117. package/src/core/common/services/core-s3.service.ts +9 -1
  118. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  119. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  120. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  121. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  122. package/src/core/modules/file/README.md +61 -0
  123. package/src/core/modules/file/core-file.service.ts +166 -9
  124. package/src/core/modules/file/file-access.helper.ts +149 -0
  125. package/src/core/modules/file/file-metadata.helper.ts +13 -5
  126. package/src/core/modules/file/file-roles.config.ts +202 -0
  127. package/src/core/modules/file/file-roles.helper.ts +11 -40
  128. package/src/core/modules/tenant/README.md +20 -1
  129. package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
  130. package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
  131. package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
  132. package/src/core/modules/tenant/core-tenant.module.ts +12 -0
  133. package/src/core/modules/tenant/core-tenant.service.ts +56 -0
  134. package/src/core/modules/tus/README.md +46 -7
  135. package/src/core/modules/tus/core-tus.service.ts +94 -5
  136. package/src/core/modules/tus/index.ts +1 -0
  137. package/src/core/modules/tus/tus.constants.ts +17 -1
  138. package/src/core/modules/user/core-user.service.ts +12 -0
  139. package/src/core/modules/user/inputs/core-user.input.ts +28 -2
  140. package/src/core.module.ts +62 -1
  141. package/src/index.ts +3 -0
  142. package/src/server/modules/file/file.service.ts +45 -2
  143. package/src/test/test.helper.ts +54 -11
@@ -0,0 +1,192 @@
1
+ // `require`, NOT an ESM import: `@nestjs/graphql` builds the schema with the instance Node's CJS
2
+ // cache holds, and graphql REFUSES a schema "from another module or realm". An ESM import can resolve
3
+ // to a second copy of the module (a bundler's interop namespace, or a duplicated install), and the
4
+ // wrapper would then reject the very schema it was handed. The repo already uses this form for
5
+ // `graphql-upload` and `lodash` for the same reason.
6
+ import graphql = require('graphql');
7
+
8
+ import { getTenantContextResolver } from '../services/core-tenant-context.registry';
9
+ import { ConfigService } from '../services/config.service';
10
+ import { IRequestContext, RequestContext } from '../services/request-context.service';
11
+
12
+ /**
13
+ * A `RequestContext` for GraphQL operations that arrive over a WEBSOCKET.
14
+ *
15
+ * WHY THIS EXISTS — the gap it closes is a silent, complete tenant bypass:
16
+ *
17
+ * `RequestContextMiddleware` is Express middleware (`consumer.apply(...).forRoutes('*')`). A
18
+ * WebSocket UPGRADE is not a `request` event, so it traverses no middleware — and `CoreTenantGuard`
19
+ * cannot help either, because its `getRequest()` looks for `context.req`, which a subscription
20
+ * context (the graphql-ws `extra` object) does not have, so the guard returns `true` without
21
+ * deciding anything.
22
+ *
23
+ * The consequence was invisible from the query itself. `mongooseTenantPlugin.shouldBypass()` reads
24
+ * "no RequestContext" as "system operation, no filter" — correct for a cron job or a migration, and
25
+ * exactly wrong here. So a tenant-scoped read performed while delivering a subscription message
26
+ * returned EVERY tenant's rows, and the plugin's own safety net (which throws when a tenant-scoped
27
+ * schema is touched without a tenant) never fired, because there was no context to notice.
28
+ *
29
+ * WHERE IT HOOKS IN: `GqlSubscriptionService` reads `execute` and `subscribe` off the GraphQL module
30
+ * options and hands them to BOTH WebSocket transports (`graphql-ws` and `subscriptions-transport-ws`),
31
+ * defaulting to graphql-js's own. Those two functions are the only place where the whole WS operation
32
+ * — including every field resolver and the `resolve`/`filter` callbacks of a subscription — is
33
+ * reachable as one call. HTTP does not go through them (Apollo runs its own pipeline), so wrapping
34
+ * them touches the WS path only.
35
+ *
36
+ * THE ASYNC-ITERATOR PART IS LOAD-BEARING. `subscribe()` returns an AsyncIterator, and graphql-js
37
+ * runs the per-event execution — `resolve`, `filter`, every field resolver — inside that iterator's
38
+ * `next()`, which the transport pulls LONG AFTER `subscribe()` returned. Establishing the context
39
+ * only around the `subscribe()` call therefore covers the initial subscribe and nothing else: the
40
+ * store is gone by the time the first message is delivered. Wrapping `next()` (and `return`/`throw`)
41
+ * is what puts every delivered message inside the context.
42
+ */
43
+
44
+ /** Minimal shape of what graphql-js `execute`/`subscribe` receive. */
45
+ interface GraphQlArgs {
46
+ contextValue?: any;
47
+ [key: string]: any;
48
+ }
49
+
50
+ type ExecuteFn = (args: GraphQlArgs) => any;
51
+
52
+ /**
53
+ * Read the tenant header out of whatever the transport left on the context.
54
+ *
55
+ * Both WS transports are covered, and the reason there are three sources is that they genuinely
56
+ * differ: `graphql-ws` gets `extra.headers` from `CoreModule`'s `onConnect` (the client's
57
+ * `connectionParams`), `subscriptions-transport-ws` returns `{ headers }` from its own `onConnect`,
58
+ * and the raw upgrade request is the fallback for a project with a custom `onConnect` that forwards
59
+ * neither. Header names are matched case-insensitively — `connectionParams` are client-supplied JSON
60
+ * and arrive with whatever casing the client used, unlike Node's lower-cased HTTP headers.
61
+ */
62
+ function readTenantHeader(contextValue: any): string | undefined {
63
+ const headerName = (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
64
+ const sources = [contextValue?.headers, contextValue?.connectionParams, contextValue?.request?.headers];
65
+ for (const source of sources) {
66
+ if (!source || typeof source !== 'object') {
67
+ continue;
68
+ }
69
+ for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
70
+ if (key.toLowerCase() === headerName && typeof value === 'string' && value) {
71
+ return value;
72
+ }
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /** The user the connection authenticated as, as `CoreModule`'s `onConnect` recorded it. */
79
+ function readUser(contextValue: any): any {
80
+ return contextValue?.user ?? contextValue?.extra?.user ?? undefined;
81
+ }
82
+
83
+ /**
84
+ * Build the context for one WebSocket operation.
85
+ *
86
+ * Returns `undefined` when the operation is NOT a WebSocket one (an HTTP context carries `req`, and
87
+ * the middleware has already established a context for it) — wrapping that again would replace a
88
+ * live, lazily-evaluated context with a snapshot.
89
+ */
90
+ export async function buildWsRequestContext(contextValue: any): Promise<IRequestContext | undefined> {
91
+ if (!contextValue || typeof contextValue !== 'object' || contextValue.req) {
92
+ return undefined;
93
+ }
94
+
95
+ const user = readUser(contextValue);
96
+ const context: IRequestContext = { currentUser: user };
97
+
98
+ // Language: same source as the HTTP path, when the transport carried it.
99
+ const acceptLanguage =
100
+ contextValue.headers?.['accept-language'] ??
101
+ contextValue.connectionParams?.['accept-language'] ??
102
+ contextValue.request?.headers?.['accept-language'];
103
+ if (typeof acceptLanguage === 'string') {
104
+ context.language = acceptLanguage;
105
+ }
106
+
107
+ const resolver = getTenantContextResolver();
108
+ if (resolver) {
109
+ // A failure here must NOT be answered with an unscoped context — that is the leak this file
110
+ // exists to close. Leaving the tenant fields unset makes the plugin's safety net refuse
111
+ // tenant-scoped reads, which is the safe direction.
112
+ try {
113
+ Object.assign(context, await resolver.resolve(user, readTenantHeader(contextValue)));
114
+ } catch {
115
+ // Intentionally swallowed: see above. Non-tenant operations keep working.
116
+ }
117
+ }
118
+
119
+ return context;
120
+ }
121
+
122
+ /**
123
+ * Run an AsyncIterator's pulls inside `context`.
124
+ *
125
+ * `next()` is where graphql-js executes the per-event selection set, so this is what carries the
126
+ * context into every delivered message rather than only into the initial subscribe.
127
+ */
128
+ export function withRequestContextAsyncIterator<T>(
129
+ iterator: AsyncIterator<T> & { [Symbol.asyncIterator]?: () => AsyncIterator<T> },
130
+ context: IRequestContext,
131
+ ): AsyncIterableIterator<T> {
132
+ const wrapped: AsyncIterableIterator<T> = {
133
+ next: (...args: [] | [undefined]) => RequestContext.run(context, () => iterator.next(...(args as []))),
134
+ [Symbol.asyncIterator]() {
135
+ return wrapped;
136
+ },
137
+ };
138
+ if (iterator.return) {
139
+ wrapped.return = (value?: any) => RequestContext.run(context, () => iterator.return!(value));
140
+ }
141
+ if (iterator.throw) {
142
+ wrapped.throw = (error?: any) => RequestContext.run(context, () => iterator.throw!(error));
143
+ }
144
+ return wrapped;
145
+ }
146
+
147
+ /** Is this an AsyncIterator (a subscription source) rather than a plain execution result? */
148
+ function isAsyncIterable(value: any): boolean {
149
+ return !!value && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
150
+ }
151
+
152
+ /**
153
+ * Wrap graphql-js `execute` so a query/mutation sent over the WebSocket runs in a request context.
154
+ *
155
+ * Not only subscriptions: `graphql-ws` carries queries and mutations too, and those had the same gap.
156
+ */
157
+ export function createRequestContextAwareExecute(execute: ExecuteFn): ExecuteFn {
158
+ return async (args: GraphQlArgs) => {
159
+ const context = await buildWsRequestContext(args?.contextValue);
160
+ if (!context) {
161
+ return execute(args);
162
+ }
163
+ return RequestContext.run(context, () => execute(args));
164
+ };
165
+ }
166
+
167
+ /** Wrap graphql-js `subscribe` so both the subscribe AND every delivered message run in a context. */
168
+ export function createRequestContextAwareSubscribe(subscribe: ExecuteFn): ExecuteFn {
169
+ return async (args: GraphQlArgs) => {
170
+ const context = await buildWsRequestContext(args?.contextValue);
171
+ if (!context) {
172
+ return subscribe(args);
173
+ }
174
+ const result = await RequestContext.run(context, () => subscribe(args));
175
+ return isAsyncIterable(result) ? withRequestContextAsyncIterator(result as AsyncIterator<any>, context) : result;
176
+ };
177
+ }
178
+
179
+ /**
180
+ * The pair `CoreModule` installs — graphql's own `execute` / `subscribe`, wrapped.
181
+ *
182
+ * Zero-arg factories rather than pre-built constants so nothing is captured at module-evaluation
183
+ * time, and so `core.module.ts` needs no graphql import of its own (see the `require` note at the top
184
+ * of this file for why that import form matters).
185
+ */
186
+ export function buildRequestContextAwareExecute(): (args: GraphQlArgs) => any {
187
+ return createRequestContextAwareExecute(graphql.execute as any);
188
+ }
189
+
190
+ export function buildRequestContextAwareSubscribe(): (args: GraphQlArgs) => any {
191
+ return createRequestContextAwareSubscribe(graphql.subscribe as any);
192
+ }
@@ -198,10 +198,26 @@ export class GridFSHelper {
198
198
  }
199
199
 
200
200
  /**
201
- * Find file metadata by filename
201
+ * Find file metadata by filename — the MOST RECENT file with that name.
202
+ *
203
+ * The sort is load-bearing, not cosmetic. `bucket.find({ filename })` returns natural order,
204
+ * which in practice is the OLDEST document first, while `openDownloadStreamByName()` defaults to
205
+ * `revision: -1` — the NEWEST. Filenames are unique in no store and are client-supplied, so with
206
+ * two files of one name this method answered one document and the download served another: an
207
+ * ownership rule built on `getRawFileInfoByName()` approved the caller's own file and the stream
208
+ * handed over the other one.
209
+ *
210
+ * Newest-first (rather than oldest-first) because it is the BYTES semantics that must not move:
211
+ * GridFS treats a re-upload under the same name as a new revision and serves the latest, so
212
+ * aligning the metadata lookup with that keeps every existing download answering the same bytes
213
+ * and only corrects the document describing them. `_id` breaks the tie, so two files written in
214
+ * the same millisecond still order totally.
202
215
  */
203
216
  static async findFileByName(bucket: GridFSBucket, filename: string): Promise<GridFSFileInfo | null> {
204
- const files = await bucket.find({ filename }).toArray();
217
+ // `uploadDate` FIRST: MongoDB applies sort keys in document order, so leading with `_id` would
218
+ // make the tie-break the primary key and could disagree with the driver's own by-name revision
219
+ // order (which sorts on `uploadDate` alone).
220
+ const files = await bucket.find({ filename }, { limit: 1, sort: { uploadDate: -1, _id: -1 } }).toArray();
205
221
  return files.length > 0 ? GridFSHelper.normalizeFileInfo(files[0]) : null;
206
222
  }
207
223
 
@@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
3
3
  import { map } from 'rxjs/operators';
4
4
 
5
5
  import { getContextData } from '../helpers/context.helper';
6
+ import { hasRestrictionsCheckedMarker } from '../decorators/restrictions-checked.marker';
6
7
  import { getStringIds } from '../helpers/db.helper';
7
8
  import { processDeep } from '../helpers/input.helper';
8
9
  import { ConfigService } from '../services/config.service';
@@ -66,7 +67,9 @@ export class CheckSecurityInterceptor implements NestInterceptor {
66
67
  objectData = data;
67
68
 
68
69
  // Check if data already checked
69
- if (this.config.noteCheckedObjects && data?._objectAlreadyCheckedForRestrictions) {
70
+ // Recognised by the framework MARKER, not by a truthy property of that name — see
71
+ // restrictions-checked.marker.ts for why a document must not be able to skip securityCheck().
72
+ if (this.config.noteCheckedObjects && hasRestrictionsCheckedMarker(data)) {
70
73
  return data;
71
74
  }
72
75
 
@@ -6,6 +6,7 @@ import { resolveResponseModelClass } from '../helpers/interceptor.helper';
6
6
  import { CoreModel } from '../models/core-model.model';
7
7
  import { ConfigService } from '../services/config.service';
8
8
  import { ModelRegistry } from '../services/model-registry.service';
9
+ import { hasRestrictionsCheckedMarker } from '../decorators/restrictions-checked.marker';
9
10
 
10
11
  /**
11
12
  * Interceptor that automatically converts plain objects and Mongoose documents
@@ -48,7 +49,7 @@ export class ResponseModelInterceptor implements NestInterceptor {
48
49
  }
49
50
 
50
51
  // Already processed by another interceptor
51
- if (data._objectAlreadyCheckedForRestrictions) {
52
+ if (hasRestrictionsCheckedMarker(data)) {
52
53
  return data;
53
54
  }
54
55
 
@@ -90,7 +91,7 @@ export class ResponseModelInterceptor implements NestInterceptor {
90
91
  }
91
92
 
92
93
  // Already processed
93
- if (item._objectAlreadyCheckedForRestrictions) {
94
+ if (hasRestrictionsCheckedMarker(item)) {
94
95
  return item;
95
96
  }
96
97
 
@@ -855,6 +855,41 @@ export interface IErrorCode {
855
855
  * therefore be expressed in `checkRights()`, not by a role name alone.
856
856
  */
857
857
  export interface IFileConfig {
858
+ /**
859
+ * WHICH PROJECT CLASS this deployment is — the per-file rule, as a declaration instead of code.
860
+ *
861
+ * The role knobs below are the coarse audience filter ("may this caller reach the route at all").
862
+ * They cannot express "…but only their own", because that sentence needs data. That is what
863
+ * `CoreFileService.checkRights()` is for — and shipping it only as an `@example` to copy went wrong
864
+ * twice in this framework's own history, both times permissively. So the four shapes are presets:
865
+ *
866
+ * | value | project class |
867
+ * |-------------------|------------------------------------------------------------------|
868
+ * | `'custom'` | you override `checkRights()` yourself — **the default**, the framework abstains |
869
+ * | `'public'` | open: anyone may read and write; the role gate is the whole policy |
870
+ * | `'authenticated'` | login-restricted: every signed-in user may use every file |
871
+ * | `'owner'` | per-user: only the uploader, plus ADMIN |
872
+ * | `'tenant'` | per-tenant: only within one's own validated tenant, plus ADMIN |
873
+ *
874
+ * `'owner'` and `'tenant'` read `metadata.ownerId` / `metadata.tenantId`, which the service STAMPS
875
+ * as it writes once one of them is active — so an upload through `CoreFileService` is authorizable
876
+ * without any project code. Files written BEFORE the preset was enabled carry no such metadata and
877
+ * are therefore ADMIN-only; that is the fail-closed direction, and a one-off backfill fixes it.
878
+ *
879
+ * Two things this setting never does: it never overrides an explicit `checkRights()` override (the
880
+ * override IS the rule), and it never widens the role gate. `'public'` still requires
881
+ * `downloadRoles: [S_EVERYONE]` — declaring the class and opening the gate are two decisions on
882
+ * purpose.
883
+ *
884
+ * An UNKNOWN value resolves to `'owner'`, not to `'custom'`: a typo means somebody believes they
885
+ * have an ownership rule, and confirming that belief is the one error that cannot be seen from
886
+ * outside.
887
+ *
888
+ * @default 'custom'
889
+ * @since 11.35.0
890
+ */
891
+ access?: 'authenticated' | 'custom' | 'owner' | 'public' | 'tenant';
892
+
858
893
  /**
859
894
  * Roles allowed to DELETE files (`deleteFile` mutation).
860
895
  *
@@ -996,9 +1031,28 @@ export interface IMultiTenancy {
996
1031
 
997
1032
  /**
998
1033
  * Model names (NOT collection names) to exclude from tenant filtering.
999
- * These schemas will not have tenant isolation applied.
1000
- * The TenantMember model is always excluded automatically.
1001
- * @example ['User', 'Session']
1034
+ *
1035
+ * **This is an OFF SWITCH for data isolation, per model.** A listed schema gets no tenant filter at
1036
+ * all: every query on it sees every tenant's rows. That is correct for a genuinely global
1037
+ * collection, and it is a data leak for anything else.
1038
+ *
1039
+ * A schema is only affected if it HAS a `tenantId` field — the plugin attaches to no other. So
1040
+ * listing a model that declares `tenantId` overrides its author's intent, and since 11.35.0 the
1041
+ * plugin logs a warning naming the model when that happens. **Do not list a model just because a
1042
+ * `populate()` returns null** — that is the isolation working; scope the reference instead, or
1043
+ * confirm the referenced collection really is global.
1044
+ *
1045
+ * `User` is the one to think hardest about. It belongs here only when users are GLOBAL (one account
1046
+ * reaches several tenants, memberships carry the scope). If your users are per-tenant — they have a
1047
+ * `tenantId` — listing `User` switches isolation off for the account collection, which is usually
1048
+ * the last place you want it off. Earlier versions of this doc suggested `['User', 'Session']` as an
1049
+ * example; that suggestion is withdrawn, because it is only right for the global-user model.
1050
+ *
1051
+ * The membership model (`multiTenancy.membershipModel`, default `TenantMember`) is added
1052
+ * automatically and needs no entry — membership is tenant-spanning by design.
1053
+ *
1054
+ * @default []
1055
+ * @example [] // start here; add a model only when you can say why it is global
1002
1056
  */
1003
1057
  excludeSchemas?: string[];
1004
1058
 
@@ -1039,23 +1093,86 @@ export interface IMultiTenancy {
1039
1093
  * @default { member: 1, manager: 2, owner: 3 }
1040
1094
  * @since 11.21.0
1041
1095
  *
1096
+ * SECURITY: never name a tenant role after a GLOBAL role. `RoleEnum.ADMIN` (`'admin'`) and every
1097
+ * other member of `GLOBAL_ONLY_ROLES` is rejected here — a tenant role of that name used to
1098
+ * satisfy `@Roles(RoleEnum.ADMIN)` in tenant context, i.e. a customer could grant themselves
1099
+ * platform authority. Use a tenant-specific name (`tenantAdmin`, `spaceAdmin`) instead, and
1100
+ * declare your own platform-wide roles in {@link globalOnlyRoles}. The boot check refuses a
1101
+ * hierarchy that violates this.
1102
+ *
1042
1103
  * @example
1043
1104
  * ```typescript
1044
1105
  * // config.env.ts
1045
1106
  * multiTenancy: {
1046
- * roleHierarchy: { viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 }
1107
+ * roleHierarchy: { viewer: 1, editor: 2, manager: 2, tenantAdmin: 3, owner: 4 }
1047
1108
  * }
1048
1109
  *
1049
1110
  * // roles.ts
1050
1111
  * import { createHierarchyRoles } from '@lenne.tech/nest-server';
1051
- * export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 });
1112
+ * export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, tenantAdmin: 3, owner: 4 });
1052
1113
  *
1053
1114
  * // resolver.ts
1054
- * @Roles(HR.EDITOR) // requires level >= 2 (editor, manager, admin, owner)
1115
+ * @Roles(HR.EDITOR) // requires level >= 2 (editor, manager, tenantAdmin, owner)
1055
1116
  * ```
1056
1117
  */
1057
1118
  roleHierarchy?: Record<string, number>;
1058
1119
 
1120
+ /**
1121
+ * Project-defined roles that carry GLOBAL (platform-wide) authority.
1122
+ *
1123
+ * `RoleEnum.ADMIN` is always treated this way; this option extends the set with your own.
1124
+ * A role listed here is ALWAYS resolved against `user.roles` and NEVER against a tenant
1125
+ * membership role — no matter what the `X-Tenant-Id` header says.
1126
+ *
1127
+ * **Why this matters.** Membership roles are customer-assigned free text, and in tenant context
1128
+ * required roles are otherwise compared against `membership.role`. Without this list, a global
1129
+ * endpoint guarded by `@Roles('auditor')` is reachable by any tenant member whose membership role
1130
+ * happens to be `'auditor'` — granted by their own tenant owner, not by you.
1131
+ *
1132
+ * Declaring a role here is therefore a security statement: "only the platform may grant this".
1133
+ * A role that appears BOTH here and in {@link roleHierarchy} fails the boot check, because it
1134
+ * would have to be resolved against two different sources at once.
1135
+ *
1136
+ * @default [] (only RoleEnum.ADMIN is global)
1137
+ * @since 11.35.0
1138
+ *
1139
+ * @example
1140
+ * ```typescript
1141
+ * multiTenancy: {
1142
+ * roleHierarchy: { member: 1, tenantAdmin: 2, owner: 3 }, // per tenant
1143
+ * globalOnlyRoles: ['auditor', 'support'], // platform-wide
1144
+ * }
1145
+ * ```
1146
+ */
1147
+ globalOnlyRoles?: string[];
1148
+
1149
+ /**
1150
+ * Refuse membership roles that are not declared anywhere (deny by default).
1151
+ *
1152
+ * When `true`, `addMember()` / `updateMemberRole()` accept only roles present in
1153
+ * {@link roleHierarchy} or {@link additionalMembershipRoles}. When `false` (default, backward
1154
+ * compatible), any non-reserved string is accepted.
1155
+ *
1156
+ * An undeclared membership role can never GRANT anything either way — the guards only match
1157
+ * declared tenant roles — but with this on, the mistake surfaces as a 400 at assignment time
1158
+ * instead of as a membership that silently authorizes nothing.
1159
+ *
1160
+ * @default false
1161
+ * @since 11.35.0
1162
+ */
1163
+ strictMembershipRoles?: boolean;
1164
+
1165
+ /**
1166
+ * Membership roles that are valid but carry no hierarchy level (exact-match roles).
1167
+ *
1168
+ * Only consulted when {@link strictMembershipRoles} is `true`. Use it for roles like `'auditor'`
1169
+ * that are meaningful per tenant but do not sit in the level ordering.
1170
+ *
1171
+ * @default []
1172
+ * @since 11.35.0
1173
+ */
1174
+ additionalMembershipRoles?: string[];
1175
+
1059
1176
  /**
1060
1177
  * TTL in milliseconds for the tenant guard's in-memory membership cache.
1061
1178
  * The cache avoids repeated DB lookups when the same user accesses the same tenant.
@@ -2210,7 +2327,8 @@ export interface IServerOptions {
2210
2327
  *
2211
2328
  * // Enable with excluded schemas and custom header
2212
2329
  * multiTenancy: {
2213
- * excludeSchemas: ['User', 'Session'],
2330
+ * // excludeSchemas turns isolation OFF per model — see IMultiTenancy.excludeSchemas
2331
+ * excludeSchemas: [],
2214
2332
  * headerName: 'x-tenant-id',
2215
2333
  * },
2216
2334
  * ```
@@ -2852,6 +2970,20 @@ export interface IS3Config {
2852
2970
  presignedDownloads?:
2853
2971
  | boolean
2854
2972
  | {
2973
+ /**
2974
+ * Explicitly disable while keeping the settings around — the repo-wide
2975
+ * "presence implies enabled, unless `enabled: false`" pattern.
2976
+ *
2977
+ * It is spelled out in the type because it is REACHABLE WITHOUT THE TYPE: `NEST_SERVER_CONFIG`
2978
+ * and the `NSC__*` variables deliver plain JSON, so `{"enabled": false}` could always be set —
2979
+ * and `CoreS3Service` used to read any object as "enabled", which meant presigned downloads
2980
+ * came on while the boot warning (which does honour the key) stayed silent. Two code paths
2981
+ * answering one question differently is how a bearer-capability download gets switched on by
2982
+ * accident.
2983
+ * @default true (when the config object is present)
2984
+ */
2985
+ enabled?: boolean;
2986
+
2855
2987
  /**
2856
2988
  * Presigned URL validity in seconds.
2857
2989
  *
@@ -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
+ }