@lenne.tech/nest-server 11.34.1 → 11.35.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 (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/file.helper.js +3 -2
  25. package/dist/core/common/helpers/file.helper.js.map +1 -1
  26. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
  27. package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
  28. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
  29. package/dist/core/common/helpers/gridfs.helper.js +1 -1
  30. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  31. package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
  32. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  33. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  34. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  35. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  36. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  37. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  38. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  39. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  40. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  41. package/dist/core/common/services/core-s3.service.js +4 -1
  42. package/dist/core/common/services/core-s3.service.js.map +1 -1
  43. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  44. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  45. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  46. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  47. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  48. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  49. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  50. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  51. package/dist/core/modules/file/core-file-access-audit.initializer.d.ts +13 -0
  52. package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
  53. package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
  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 +31 -0
  63. package/dist/core/modules/file/file-roles.config.js +127 -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 +16 -34
  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 +13 -1
  96. package/dist/core.module.js.map +1 -1
  97. package/dist/index.d.ts +7 -0
  98. package/dist/index.js +7 -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.x-to-11.35.x.md +846 -0
  108. package/migration-guides/11.35.0-to-11.35.1.md +152 -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/file.helper.ts +11 -5
  117. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  118. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  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/plugins/mongoose-system-role.plugin.ts +193 -0
  123. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  124. package/src/core/common/services/core-s3.service.ts +9 -1
  125. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  126. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  127. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  128. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  129. package/src/core/modules/file/README.md +71 -0
  130. package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -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 +357 -0
  135. package/src/core/modules/file/file-roles.helper.ts +24 -60
  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 +72 -3
  149. package/src/index.ts +7 -0
  150. package/src/server/modules/file/file.service.ts +45 -2
  151. package/src/test/test.helper.ts +54 -11
@@ -18,6 +18,7 @@ import {
18
18
  FilesystemFileHelper,
19
19
  } from '../file/filesystem-file.helper';
20
20
  import { S3_FILES_COLLECTION, S3FileHelper } from '../file/s3-file.helper';
21
+ import { TUS_OWNER_METADATA_KEY } from './tus.constants';
21
22
  import { TusRedisLocker } from './tus-redis-locker';
22
23
  import {
23
24
  DEFAULT_TUS_ALLOWED_HEADERS,
@@ -181,6 +182,11 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
181
182
  const contentType = metadata.filetype || 'application/octet-stream';
182
183
  const fileMetadata = {
183
184
  originalMetadata: metadata,
185
+ // The key `CoreFileService.checkRights()` documents for a per-file ownership rule. Without it
186
+ // a tus-uploaded file could never satisfy that rule — it failed CLOSED for everyone but
187
+ // ADMIN, so a project following the documented pattern ended up with undownloadable files.
188
+ // Written from the SERVER-recorded owner, never from the client's own metadata.
189
+ ...(metadata[TUS_OWNER_METADATA_KEY] ? { ownerId: metadata[TUS_OWNER_METADATA_KEY] } : {}),
184
190
  tusUploadId: upload.id,
185
191
  uploadedAt: new Date(),
186
192
  };
@@ -460,6 +466,79 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
460
466
  await this.deleteStagedUpload(upload.id);
461
467
  }
462
468
 
469
+ /**
470
+ * The authenticated caller's id, as the upstream role guard left it on the request.
471
+ *
472
+ * `CoreTusController.handleTus(req, res)` receives the Express request, so `req.user` is whatever
473
+ * the guard put there. But `@tus/server` v2 does NOT hand that object to its hooks: `handle()`
474
+ * converts the Node request into a WHATWG `ServerRequest` (srvx) first, and anything the guard
475
+ * attached lives on the ORIGINAL request, reachable through `runtime.node.req`. Reading only
476
+ * `req.user` therefore finds nothing and every upload silently becomes owner-less — which fails in
477
+ * the permissive direction, so it would not have shown up as an error anywhere.
478
+ *
479
+ * Returns `undefined` for an unauthenticated request, which is a legitimate state: a project may
480
+ * open the gate with `tus.roles: [RoleEnum.S_EVERYONE]` for a public form.
481
+ *
482
+ * Override to read the owner from somewhere else (an API key, a signed form token).
483
+ */
484
+ protected readRequestUserId(req: any): string | undefined {
485
+ const id = req?.user?.id ?? req?.runtime?.node?.req?.user?.id ?? req?.context?.user?.id;
486
+ return id === undefined || id === null || id === '' ? undefined : String(id);
487
+ }
488
+
489
+ /**
490
+ * Refuse a request that names an upload the caller does not own.
491
+ *
492
+ * `tus.roles` is the coarse gate — who may reach the endpoint at all. This is the fine one: HEAD
493
+ * (read the offset), PATCH (APPEND BYTES) and DELETE (terminate) all address an upload by its URL,
494
+ * and without this any authenticated caller who learns an id could resume, overwrite or destroy
495
+ * somebody else's upload. Appending is the sharpest: those bytes are then migrated into the file
496
+ * store under the ORIGINAL uploader's filename, so the attacker's content is served as the victim's.
497
+ *
498
+ * **Refuses with 404, never 403** — the same policy `CoreFileService.checkRights()` states: a
499
+ * refusal must be indistinguishable from an upload that does not exist, or the endpoint becomes an
500
+ * existence oracle for ids that are not secrets.
501
+ *
502
+ * **An OWNER-LESS upload stays reachable by anyone who may reach the endpoint.** That is deliberate
503
+ * and load-bearing for backward compatibility: uploads created before 11.35.0 carry no owner, and
504
+ * so does every upload of a project that deliberately runs the endpoint public. Denying those would
505
+ * turn an upgrade into a fleet-wide breakage of in-flight uploads and break a documented
506
+ * configuration. What is closed is the case that actually leaks: an upload that HAS an owner being
507
+ * touched by somebody else.
508
+ *
509
+ * Override to widen this — e.g. to let a support role resume any upload.
510
+ */
511
+ protected async assertUploadOwnership(req: any, uploadId: string): Promise<void> {
512
+ // No id: this is a creation (POST) or a server-capability request (OPTIONS). Nothing to own yet.
513
+ if (!uploadId) {
514
+ return;
515
+ }
516
+
517
+ let owner: string | undefined;
518
+ try {
519
+ const upload = await (this.tusServer as any)?.datastore?.getUpload?.(uploadId);
520
+ const recorded = upload?.metadata?.[TUS_OWNER_METADATA_KEY];
521
+ owner = recorded === undefined || recorded === null ? undefined : String(recorded);
522
+ } catch {
523
+ // Unknown id, or a store that cannot answer right now. Say nothing and let the tus server
524
+ // produce its own 404 — inventing a refusal here would also refuse legitimate retries during a
525
+ // transient store outage.
526
+ return;
527
+ }
528
+
529
+ if (!owner) {
530
+ return; // owner-less upload — see the note above
531
+ }
532
+
533
+ const caller = this.readRequestUserId(req);
534
+ if (caller && caller === owner) {
535
+ return;
536
+ }
537
+
538
+ this.logger.warn(`Refused tus request for upload ${uploadId}: caller is not its owner`);
539
+ throw Object.assign(new Error('Upload not found'), { body: 'Upload not found', status_code: 404 });
540
+ }
541
+
463
542
  /**
464
543
  * Validate file type against allowedTypes configuration
465
544
  *
@@ -519,11 +598,20 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
519
598
  datastore,
520
599
  ...(locker ? { locker } : {}),
521
600
  maxSize: this.config.maxSize,
522
- onUploadCreate: async (_req, upload) => {
601
+ // Every request that names an upload — HEAD (offset), PATCH (append bytes), DELETE
602
+ // (terminate) — is checked against the upload's recorded owner. `tus.roles` alone cannot do
603
+ // this: it decides who may reach the endpoint, not which upload they may touch, and the tus
604
+ // protocol is built around a per-upload URL. Appending is the sharp end — bytes PATCHed into
605
+ // somebody else's upload are migrated into the file store under THEIR filename.
606
+ onIncomingRequest: async (req, uploadId) => this.assertUploadOwnership(req, uploadId),
607
+ onUploadCreate: async (req, upload) => {
608
+ // Record the creator FIRST, so the ownership check below has something to compare against.
609
+ // Overwrites rather than merges: see TUS_OWNER_METADATA_KEY.
610
+ const metadata = { ...upload.metadata, [TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null };
611
+
523
612
  // Validate file type if allowedTypes is configured
524
613
  if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
525
- const metadata = this.parseMetadata(upload.metadata);
526
- const filetype = metadata.filetype;
614
+ const filetype = this.parseMetadata(upload.metadata).filetype;
527
615
 
528
616
  if (!this.validateFileType(filetype)) {
529
617
  const allowedList = this.config.allowedTypes.join(', ');
@@ -541,8 +629,9 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
541
629
  }
542
630
  }
543
631
 
544
- // Return empty object to proceed with upload
545
- return {};
632
+ // Returning the metadata is what PERSISTS the owner — the tus server writes the returned
633
+ // value onto the upload. Returning `{}` here would keep the client's own metadata verbatim.
634
+ return { metadata };
546
635
  },
547
636
  onUploadFinish: async (_req, upload) => {
548
637
  try {
@@ -2,4 +2,5 @@
2
2
  export * from './core-tus.controller';
3
3
  export * from './core-tus.service';
4
4
  export * from './interfaces/tus-config.interface';
5
+ export * from './tus.constants';
5
6
  export * from './tus.module';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Dependency-injection tokens of the TUS module.
2
+ * Dependency-injection tokens AND cross-file constants of the TUS module.
3
3
  *
4
4
  * They live in a dedicated, import-free leaf file — never in `tus.module.ts` or a service — so that
5
5
  * no file needing a token has to import the module (or vice versa) and close an import cycle.
@@ -23,3 +23,19 @@
23
23
  * Injected type: `Required<ITusConfig>`.
24
24
  */
25
25
  export const TUS_CONFIG = 'TUS_CONFIG';
26
+
27
+ /**
28
+ * Metadata key recording WHO created a tus upload, in the upload's own (staged) metadata.
29
+ *
30
+ * Framework-owned and ALWAYS overwritten in `onUploadCreate` — metadata otherwise arrives from the
31
+ * client in the `Upload-Metadata` header, so a merged value would let a caller declare somebody else
32
+ * as the owner and hand themselves access to the victim's upload URL.
33
+ *
34
+ * Deliberately NOT `ownerId`: that is the key of the FINISHED FILE's metadata (the one
35
+ * `CoreFileService.checkRights()` documents), and keeping the two distinct means a project's own
36
+ * `ownerId` metadata on a file cannot be confused with the upload-time record.
37
+ *
38
+ * It lives here rather than in the service for the same reason the token above does — and because
39
+ * `tests/unit/import-cycle-invariants.spec.ts` enforces exactly that placement.
40
+ */
41
+ export const TUS_OWNER_METADATA_KEY = 'ltOwnerId';
@@ -4,6 +4,7 @@ import crypto = require('crypto');
4
4
  import { sha256 } from 'js-sha256';
5
5
  import { Document, Model } from 'mongoose';
6
6
 
7
+ import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
7
8
  import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
8
9
  import { ServiceOptions } from '../../common/interfaces/service-options.interface';
9
10
  import { ConfigService } from '../../common/services/config.service';
@@ -263,6 +264,17 @@ export abstract class CoreUserService<
263
264
  throw new BadRequestException('Roles contains invalid values');
264
265
  }
265
266
 
267
+ // Reject system roles (s_*). This is the framework's canonical "assign roles" API and it writes
268
+ // straight through findByIdAndUpdate, so neither MapAndValidatePipe nor check() sees the values.
269
+ // mongooseSystemRolePlugin is the backstop below this; the explicit check here exists to fail
270
+ // before the DB round-trip and with a message naming the offending values.
271
+ const systemRoles = roles.filter((role) => looksLikeSystemRole(role));
272
+ if (systemRoles.length) {
273
+ throw new BadRequestException(
274
+ `System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles: ${systemRoles.join(', ')}`,
275
+ );
276
+ }
277
+
266
278
  // Update and return user
267
279
  return this.process(
268
280
  async () => {
@@ -1,10 +1,10 @@
1
1
  import { InputType } from '@nestjs/graphql';
2
- import { IsEmail } from 'class-validator';
2
+ import { IsEmail, IsString, Matches } from 'class-validator';
3
3
 
4
4
  import { Restricted } from '../../../common/decorators/restricted.decorator';
5
5
  import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
6
6
  import { ProcessType } from '../../../common/enums/process-type.enum';
7
- import { RoleEnum } from '../../../common/enums/role.enum';
7
+ import { RoleEnum, SYSTEM_ROLE_PREFIX, SYSTEM_ROLE_REJECT_PATTERN } from '../../../common/enums/role.enum';
8
8
  import { CoreInput } from '../../../common/inputs/core-input.input';
9
9
 
10
10
  /**
@@ -50,12 +50,38 @@ export abstract class CoreUserInput extends CoreInput {
50
50
 
51
51
  /**
52
52
  * Roles of the user
53
+ *
54
+ * System roles (`s_*` prefix, see RoleEnum) are runtime-context checks and must NEVER be stored:
55
+ * `hasRole` is a plain string intersection, so a stored `s_self` would satisfy every S_SELF
56
+ * check (e.g. update/delete of ARBITRARY users) without the account carrying a real role.
57
+ * The @Restricted above limits WHO may send the field; this validator limits WHAT it may hold.
58
+ *
59
+ * A rejected value answers HTTP 400 (`Validation failed for 1 field: roles (matches)`, with the
60
+ * per-field message in the response body). `CoreUserCreateInput` inherits this field, so sign-up
61
+ * and user creation are covered too.
62
+ *
63
+ * This validator is the FIRST of three layers, not the only one — it gives the best error
64
+ * message, but a subclass that redeclares `roles` replaces it (MapAndValidatePipe walks the
65
+ * prototype chain child-first and skips a property once a child class has validated it), and
66
+ * `force: true` / direct Mongoose writes never reach it at all. `CoreUserService.setRoles()` and
67
+ * the unconditional `mongooseSystemRolePlugin` close those paths.
53
68
  */
54
69
  @Restricted({ processType: ProcessType.INPUT, roles: RoleEnum.ADMIN })
55
70
  @UnifiedField({
71
+ description: 'Roles of the user (system roles with s_ prefix are rejected)',
56
72
  isArray: true,
57
73
  isOptional: true,
58
74
  type: String,
75
+ // `opts` already carries `each: true` for array fields — do not hardcode it, or array-ness has
76
+ // two sources of truth. Note that supplying `validator` REPLACES the built-in `IsString`, so it
77
+ // is re-declared here explicitly rather than relying on `matches()` rejecting non-strings.
78
+ validator: (opts) => [
79
+ IsString(opts),
80
+ Matches(SYSTEM_ROLE_REJECT_PATTERN, {
81
+ ...opts,
82
+ message: `System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles`,
83
+ }),
84
+ ],
59
85
  })
60
86
  roles?: string[] = undefined;
61
87
 
@@ -1,6 +1,6 @@
1
1
  import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
2
2
  import { DynamicModule, Global, MiddlewareConsumer, Module, NestModule, UnauthorizedException } from '@nestjs/common';
3
- import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
3
+ import { APP_INTERCEPTOR, APP_PIPE, DiscoveryModule } from '@nestjs/core';
4
4
  import { GraphQLModule } from '@nestjs/graphql';
5
5
  import { MongooseModule } from '@nestjs/mongoose';
6
6
  import type { Context } from 'graphql-ws';
@@ -8,6 +8,10 @@ import graphqlUploadExpress = require('graphql-upload/graphqlUploadExpress.js');
8
8
  import mongoose from 'mongoose';
9
9
 
10
10
  import { merge } from './core/common/helpers/config.helper';
11
+ import {
12
+ buildRequestContextAwareExecute,
13
+ buildRequestContextAwareSubscribe,
14
+ } from './core/common/helpers/graphql-ws-context.helper';
11
15
  import { CheckResponseInterceptor } from './core/common/interceptors/check-response.interceptor';
12
16
  import { CheckSecurityInterceptor } from './core/common/interceptors/check-security.interceptor';
13
17
  import { ResponseModelInterceptor } from './core/common/interceptors/response-model.interceptor';
@@ -31,6 +35,7 @@ import { mongooseIdPlugin } from './core/common/plugins/mongoose-id.plugin';
31
35
  import { mongooseAuditFieldsPlugin } from './core/common/plugins/mongoose-audit-fields.plugin';
32
36
  import { mongoosePasswordPlugin } from './core/common/plugins/mongoose-password.plugin';
33
37
  import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-guard.plugin';
38
+ import { mongooseSystemRolePlugin } from './core/common/plugins/mongoose-system-role.plugin';
34
39
  import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
35
40
  import { ConfigService } from './core/common/services/config.service';
36
41
  import { CoreCronJobsInitializer } from './core/common/services/core-cron-jobs.initializer';
@@ -46,7 +51,8 @@ import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better
46
51
  import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
47
52
  import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
48
53
  import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
49
- import { applyFileRoles } from './core/modules/file/file-roles.helper';
54
+ import { CoreFileAccessAuditInitializer } from './core/modules/file/core-file-access-audit.initializer';
55
+ import { applyFileRoles, warnOnPresignedDownloadsWithRestrictedRoles } from './core/modules/file/file-roles.helper';
50
56
  import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
51
57
  import { CoreHubModule } from './core/modules/hub/core-hub.module';
52
58
  import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
@@ -253,6 +259,12 @@ export class CoreModule implements NestModule {
253
259
  if (config.security?.mongooseRoleGuardPlugin !== false) {
254
260
  connection.plugin(mongooseRoleGuardPlugin);
255
261
  }
262
+ // Refuse to store system roles (s_*) in a roles array. Deliberately NOT configurable and
263
+ // registered independently of the role guard above: that one decides WHO may change roles
264
+ // (and can be switched off), this one decides WHICH values may exist at all. A stored
265
+ // 's_self' satisfies every S_SELF check on arbitrary users, so there is no configuration
266
+ // under which writing one is legitimate.
267
+ connection.plugin(mongooseSystemRolePlugin);
256
268
  // Add audit fields plugin (enabled by default, opt-out via config)
257
269
  if (config.security?.mongooseAuditFieldsPlugin !== false) {
258
270
  connection.plugin(mongooseAuditFieldsPlugin);
@@ -273,6 +285,11 @@ export class CoreModule implements NestModule {
273
285
  // function and opt out of this; see applyFileRoles() for why.
274
286
  applyFileRoles(config.file);
275
287
 
288
+ // Presigned downloads hand out a bearer URL that ignores roles after it is issued, so pairing
289
+ // them with a restricted downloadRoles is usually an oversight. Warn rather than fail — the
290
+ // combination has legitimate uses, it just should not be reached by accident.
291
+ warnOnPresignedDownloadsWithRestrictedRoles(config.s3, config.file);
292
+
276
293
  // Check secrets
277
294
  const jwtConfig = config.jwt;
278
295
  if (jwtConfig?.secret && jwtConfig.secret && jwtConfig.refresh && jwtConfig.refresh.secret === jwtConfig.secret) {
@@ -289,6 +306,11 @@ export class CoreModule implements NestModule {
289
306
 
290
307
  // Core Services
291
308
  CoreCronJobsInitializer,
309
+ // Audits the file endpoints the project actually REGISTERED. applyFileRoles() above can only
310
+ // reach the base-class members; a subclass that re-declares one keeps its own @Roles() and the
311
+ // configuration never reaches that route. Only a bootstrap-time look at the registered class
312
+ // can tell those apart — see the initializer.
313
+ CoreFileAccessAuditInitializer,
292
314
  CoreRedisService,
293
315
  CoreS3Service,
294
316
  CoreTrustProxyInitializer,
@@ -365,7 +387,9 @@ export class CoreModule implements NestModule {
365
387
  }
366
388
  }
367
389
 
368
- const imports: any[] = [MongooseModule.forRoot(config.mongoose.uri, config.mongoose.options)];
390
+ // DiscoveryModule: CoreFileAccessAuditInitializer needs the registered controller/resolver
391
+ // classes, which only exist once Nest has built its route table.
392
+ const imports: any[] = [DiscoveryModule, MongooseModule.forRoot(config.mongoose.uri, config.mongoose.options)];
369
393
 
370
394
  if (isGraphQlEnabled && config.graphQl) {
371
395
  imports.push(
@@ -593,6 +617,21 @@ export class CoreModule implements NestModule {
593
617
  cors,
594
618
  installSubscriptionHandlers: true,
595
619
  subscriptions: {
620
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
621
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
622
+ // are the only place where a whole WS operation is reachable as one call. They must sit
623
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
624
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
625
+ // silently dropped.
626
+ //
627
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
628
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
629
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
630
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
631
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
632
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
633
+ execute: buildRequestContextAwareExecute(),
634
+ subscribe: buildRequestContextAwareSubscribe(),
596
635
  'graphql-ws': {
597
636
  context: ({ extra }) => extra,
598
637
  onConnect: async (context: Context<any, any>) => {
@@ -688,6 +727,21 @@ export class CoreModule implements NestModule {
688
727
  cors,
689
728
  installSubscriptionHandlers: true,
690
729
  subscriptions: {
730
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
731
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
732
+ // are the only place where a whole WS operation is reachable as one call. They must sit
733
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
734
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
735
+ // silently dropped.
736
+ //
737
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
738
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
739
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
740
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
741
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
742
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
743
+ execute: buildRequestContextAwareExecute(),
744
+ subscribe: buildRequestContextAwareSubscribe(),
691
745
  'graphql-ws': {
692
746
  context: ({ extra }) => extra,
693
747
  onConnect: async (context: Context<any, any>) => {
@@ -800,6 +854,21 @@ export class CoreModule implements NestModule {
800
854
  cors,
801
855
  installSubscriptionHandlers: true,
802
856
  subscriptions: {
857
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
858
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
859
+ // are the only place where a whole WS operation is reachable as one call. They must sit
860
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
861
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
862
+ // silently dropped.
863
+ //
864
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
865
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
866
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
867
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
868
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
869
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
870
+ execute: buildRequestContextAwareExecute(),
871
+ subscribe: buildRequestContextAwareSubscribe(),
803
872
  'graphql-ws': {
804
873
  context: ({ extra }) => extra,
805
874
  onConnect: async (context: Context<any, any>) => {
package/src/index.ts CHANGED
@@ -36,9 +36,11 @@ export * from './core/common/helpers/cookies.helper';
36
36
  export * from './core/common/helpers/graceful-shutdown.helper';
37
37
  export * from './core/common/helpers/db.helper';
38
38
  export * from './core/common/helpers/decorator.helper';
39
+ export * from './core/common/helpers/execution-context-request.helper';
39
40
  export * from './core/common/helpers/file.helper';
40
41
  export * from './core/common/helpers/filter.helper';
41
42
  export * from './core/common/helpers/graphql.helper';
43
+ export * from './core/common/helpers/graphql-ws-context.helper';
42
44
  export * from './core/common/helpers/interceptor.helper';
43
45
  export * from './core/common/helpers/gridfs.helper';
44
46
  export * from './core/common/helpers/input.helper';
@@ -81,6 +83,7 @@ export * from './core/common/plugins/mongoose-id.plugin';
81
83
  export * from './core/common/plugins/mongoose-audit-fields.plugin';
82
84
  export * from './core/common/plugins/mongoose-password.plugin';
83
85
  export * from './core/common/plugins/mongoose-role-guard.plugin';
86
+ export * from './core/common/plugins/mongoose-system-role.plugin';
84
87
  export * from './core/common/plugins/mongoose-tenant.plugin';
85
88
  export * from './core/common/scalars/any.scalar';
86
89
  export * from './core/common/scalars/date-timestamp.scalar';
@@ -93,6 +96,7 @@ export * from './core/common/services/core-cron-jobs.service';
93
96
  export * from './core/common/services/core-redis.service';
94
97
  export * from './core/common/services/core-redis-pubsub';
95
98
  export * from './core/common/services/core-s3.service';
99
+ export * from './core/common/services/core-tenant-context.registry';
96
100
  export * from './core/common/services/rate-limit-store';
97
101
  export * from './core/common/services/crud.service';
98
102
  export * from './core/common/services/email.service';
@@ -172,10 +176,12 @@ export * from './core/modules/error-code';
172
176
  // Core - Modules - File
173
177
  // =====================================================================================================================
174
178
 
179
+ export * from './core/modules/file/core-file-access-audit.initializer';
175
180
  export * from './core/modules/file/core-file-info.model';
176
181
  export * from './core/modules/file/core-file.controller';
177
182
  export * from './core/modules/file/core-file.resolver';
178
183
  export * from './core/modules/file/core-file.service';
184
+ export * from './core/modules/file/file-access.helper';
179
185
  export * from './core/modules/file/file-roles.helper';
180
186
  export * from './core/modules/file/file-storage.helper';
181
187
  export * from './core/modules/file/filesystem-file.helper';
@@ -236,6 +242,7 @@ export * from './core/modules/tenant/core-tenant-member.model';
236
242
  export * from './core/modules/tenant/core-tenant.decorators';
237
243
  export * from './core/modules/tenant/core-tenant.enums';
238
244
  export * from './core/modules/tenant/core-tenant.guard';
245
+ export * from './core/modules/tenant/core-role-scope.registry';
239
246
  export * from './core/modules/tenant/core-tenant.helpers';
240
247
  export * from './core/modules/tenant/core-tenant.module';
241
248
  export * from './core/modules/tenant/core-tenant.service';
@@ -98,12 +98,28 @@ export class FileService extends CoreFileService {
98
98
  * See `src/core/modules/file/README.md` § Access control, and
99
99
  * `tests/file-ownership.e2e-spec.ts` for the end-to-end contract test.
100
100
  */
101
+ /**
102
+ * NOTE FOR CONSUMERS: since 11.35.0 you may not need any of this.
103
+ *
104
+ * `file.access: 'owner'` is exactly the rule below, shipped by the framework — including the parts
105
+ * that are easy to get wrong (fail closed without a user, require the owner field to be PRESENT,
106
+ * cover the by-name branch, refuse a listing) and including the metadata stamping, which this project
107
+ * does by hand in `AvatarController`. `'tenant'` is the same rule against
108
+ * `metadata.tenantId` and the validated `RequestContext` tenant.
109
+ *
110
+ * This override stays because the reference server has to EXERCISE the seam — a rule that lives only
111
+ * in a preset proves the preset works, never that the inheritance point a consuming project extends
112
+ * still does. Keep it here; in your own project, prefer the preset unless your rights are something
113
+ * the framework cannot guess (an explicit read right, a case assignment, a published flag).
114
+ */
101
115
  protected override async checkRights(
102
116
  input: any,
103
117
  options?: FileServiceOptions & { checkInputType: FileInputCheckType },
104
118
  ): Promise<boolean> {
105
- // Writes, list queries and forced (system) calls stay on the coarse role gate.
106
- if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
119
+ // Forced (system) calls and WRITES stay on the coarse role gate. Writes deliberately so: an
120
+ // upload has no owner to compare against yet `AvatarController` records `metadata.ownerId` as
121
+ // it writes — so the only meaningful gate there is `file.uploadRoles`.
122
+ if (options?.force || options?.checkInputType === 'file' || options?.checkInputType === 'files') {
107
123
  return true;
108
124
  }
109
125
 
@@ -111,6 +127,33 @@ export class FileService extends CoreFileService {
111
127
  return true;
112
128
  }
113
129
 
130
+ // A LISTING cannot be narrowed by this hook, so it is refused rather than waved through.
131
+ //
132
+ // The hook is asked ONCE for the whole query, not once per row, so there is no answer here that
133
+ // means "…but only their own files". Returning `true` — which this rule used to do, and which is
134
+ // therefore the shape projects copied — hands a non-admin a full inventory of every upload the
135
+ // moment a project surfaces `findFileInfo()`: `CoreFileInfo` carries `filename`, `length`,
136
+ // `uploadDate` and the `id`, and for medical data the filename frequently IS the content.
137
+ //
138
+ // A project that wants "my files" FORCES the constraint server-side and passes `force: true`:
139
+ //
140
+ // this.fileService.findFileInfo(
141
+ // { filterQuery: { 'metadata.ownerId': String(currentUser.id) } },
142
+ // { force: true },
143
+ // );
144
+ //
145
+ // Note what that is NOT: it does not inspect the caller's `filterArgs` to check whether they are
146
+ // already narrowed. `filterArgs` is CLIENT-CONTROLLED, so approving a filter shape means
147
+ // validating attacker input — and any such check is one filter shape away from being wrong.
148
+ // Override the filter; never approve it. See tests/file-ownership.e2e-spec.ts.
149
+ // Explicit, even though falling through would ALSO deny: with `checkInputType: 'filterArgs'` the
150
+ // `input` is a FilterArgs object, so the by-name raw lookup below answers null and the comparison
151
+ // fails. That is an accident of the fall-through, not a decision — it would flip the moment a
152
+ // project's own raw lookup behaved differently for a non-string input. Say it outright instead.
153
+ if (options.checkInputType === 'filterArgs') {
154
+ return false;
155
+ }
156
+
114
157
  // The RAW document on purpose: the public getFileInfo() runs prepareOutput(), which
115
158
  // strips `metadata` — the very field this decision rests on.
116
159
  const raw =
@@ -92,6 +92,16 @@ export interface TestGraphQLOptions {
92
92
  */
93
93
  cookies?: Record<string, string> | string;
94
94
 
95
+ /**
96
+ * Additional `connectionParams` for a SUBSCRIPTION handshake.
97
+ *
98
+ * Merged on top of the `Authorization` entry derived from `token`. A WebSocket carries no HTTP
99
+ * headers per operation, so anything the server reads from a header on the HTTP path — the tenant
100
+ * header above all — has to travel here instead. Without this a tenant-scoped subscription could
101
+ * not be tested at all.
102
+ */
103
+ connectionParams?: Record<string, string>;
104
+
95
105
  /**
96
106
  * Count of subscription messages, specifies how many messages are to be received on subscription
97
107
  */
@@ -788,10 +798,13 @@ export class TestHelper {
788
798
  }
789
799
 
790
800
  // Prepare subscription
791
- let connectionParams;
801
+ let connectionParams: Record<string, string> | undefined;
792
802
  if (options?.token) {
793
803
  connectionParams = { Authorization: `Bearer ${options?.token}` };
794
804
  }
805
+ if (options?.connectionParams) {
806
+ connectionParams = { ...connectionParams, ...options.connectionParams };
807
+ }
795
808
 
796
809
  // Init client
797
810
  if (options.log) {
@@ -800,10 +813,27 @@ export class TestHelper {
800
813
  const client = createClient({ connectionParams, url: this.subscriptionUrl, webSocketImpl: ws });
801
814
  const messages: any[] = [];
802
815
  let unsubscribe: () => void;
816
+ let rejectSubscription: (reason: unknown) => void;
817
+
803
818
  const onNext = (message) => {
804
819
  if (options.log) {
805
820
  console.info('Subscription message', JSON.stringify(message, null, 2));
806
821
  }
822
+
823
+ // A REFUSED subscription arrives as a `next` message carrying `errors`, not through the
824
+ // transport's `error` callback: when a guard throws, graphql's `subscribe()` returns an
825
+ // ExecutionResult instead of an async iterable, and graphql-ws emits that as one payload
826
+ // followed by `complete`. Projecting only `data[name]` therefore turned "you may not subscribe
827
+ // to this" into an indistinguishable `undefined`/`null` message — so a test asserting that an
828
+ // unauthorized subscriber gets nothing passed just as well when the gate was wide open.
829
+ // Rejecting makes the refusal observable, which is what the caller actually experiences.
830
+ if (message?.errors?.length) {
831
+ const detail = message.errors.map((error: any) => error?.message ?? String(error)).join('; ');
832
+ rejectSubscription?.(new Error(`Subscription "${graphql.name}" failed: ${detail}`));
833
+ unsubscribe?.();
834
+ return;
835
+ }
836
+
807
837
  messages.push(message?.data?.[graphql.name]);
808
838
  if (messages.length <= options.countOfSubscriptionMessages) {
809
839
  unsubscribe();
@@ -811,16 +841,29 @@ export class TestHelper {
811
841
  };
812
842
 
813
843
  // Subscribe
814
- await new Promise((resolve, reject) => {
815
- unsubscribe = client.subscribe(
816
- { query },
817
- {
818
- complete: resolve as any,
819
- error: reject,
820
- next: onNext,
821
- },
822
- );
823
- });
844
+ //
845
+ // Disposed in `finally`, including on the refusal path: without it the client keeps its socket
846
+ // (and graphql-ws its reconnect timer) open past the end of the test, which surfaces later as a
847
+ // hanging process or as an error attributed to whichever test happened to run next.
848
+ try {
849
+ await new Promise((resolve, reject) => {
850
+ rejectSubscription = reject;
851
+ unsubscribe = client.subscribe(
852
+ { query },
853
+ {
854
+ complete: resolve as any,
855
+ error: reject,
856
+ next: onNext,
857
+ },
858
+ );
859
+ });
860
+ } finally {
861
+ try {
862
+ await client.dispose?.();
863
+ } catch {
864
+ // Already closing — nothing to clean up, and a teardown error must not mask the result.
865
+ }
866
+ }
824
867
 
825
868
  // Return subscribed messages
826
869
  return messages;