@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
@@ -10,9 +10,12 @@ import { GridFSHelper } from '../../common/helpers/gridfs.helper';
10
10
  import { check } from '../../common/helpers/input.helper';
11
11
  import { prepareOutput } from '../../common/helpers/service.helper';
12
12
  import { ConfigService } from '../../common/services/config.service';
13
+ import { RequestContext } from '../../common/services/request-context.service';
13
14
  import { CoreS3Service } from '../../common/services/core-s3.service';
14
15
  import { MaybePromise } from '../../common/types/maybe-promise.type';
15
16
  import { CoreFileInfo } from './core-file-info.model';
17
+ import { decideFileAccess, fileAccessNeedsRawDocument, resolveFileAccessPreset } from './file-access.helper';
18
+ import { warnOnUndecidedFileAccess } from './file-roles.config';
16
19
  import { FileServiceOptions } from './interfaces/file-service-options.interface';
17
20
  import { FileUploadSource } from './interfaces/file-upload.interface';
18
21
  import {
@@ -83,6 +86,17 @@ export abstract class CoreFileService {
83
86
  this.storageResolution = resolveFileStorage(ConfigService.configFastButReadOnly);
84
87
  assertFileStorageAvailable(this.storageResolution, this.isStorageAvailable(this.storageResolution.driver));
85
88
  logFileStorage(this.storageResolution);
89
+
90
+ // Warn when the gate is open and nothing declares the per-file policy — see the helper for why
91
+ // the conditions are this narrow and why it is not gated on multi-tenancy. Checked here rather than in `CoreModule.forRoot()` because only
92
+ // an instance can answer whether `checkRights()` was overridden: the base implementation is a
93
+ // fixed function, so an identity comparison against it is exact and needs no naming convention.
94
+ const config = ConfigService.configFastButReadOnly;
95
+ warnOnUndecidedFileAccess({
96
+ fileConfig: config?.file,
97
+ hasPerFileRule: this.checkRights !== CoreFileService.prototype.checkRights,
98
+ multiTenancyEnabled: !!config?.multiTenancy && config.multiTenancy.enabled !== false,
99
+ });
86
100
  }
87
101
 
88
102
  /**
@@ -148,12 +162,16 @@ export abstract class CoreFileService {
148
162
  }
149
163
  const { createReadStream, filename, mimetype } = await file;
150
164
  const readStream = createReadStream();
165
+ // Resolved ONCE for all three driver branches: under an `'owner'` / `'tenant'` preset this adds
166
+ // the very fields the preset decides on, so an upload through this service is authorizable
167
+ // without any project code. Under the default preset it is `serviceOptions.metadata` verbatim.
168
+ const metadata = this.accessMetadata(serviceOptions);
151
169
  if (this.filesystemStorage) {
152
170
  const fsFileInfo = await FilesystemFileHelper.writeFile(this.filesystemDir, this.filesystemFiles, {
153
171
  body: readStream,
154
172
  contentType: mimetype,
155
173
  filename,
156
- ...(serviceOptions?.metadata ? { metadata: serviceOptions.metadata } : {}),
174
+ ...(metadata ? { metadata } : {}),
157
175
  });
158
176
  return this.prepareOutput(fsFileInfo as unknown as CoreFileInfo, serviceOptions);
159
177
  }
@@ -165,14 +183,14 @@ export abstract class CoreFileService {
165
183
  buffer: await streamToBuffer(readStream),
166
184
  contentType: mimetype,
167
185
  filename,
168
- ...(serviceOptions?.metadata ? { metadata: serviceOptions.metadata } : {}),
186
+ ...(metadata ? { metadata } : {}),
169
187
  });
170
188
  return this.prepareOutput(s3FileInfo as unknown as CoreFileInfo, serviceOptions);
171
189
  }
172
190
  const fileInfo = await GridFSHelper.writeFileFromStream(this.files, readStream, {
173
191
  contentType: mimetype,
174
192
  filename,
175
- ...(serviceOptions?.metadata ? { metadata: serviceOptions.metadata } : {}),
193
+ ...(metadata ? { metadata } : {}),
176
194
  });
177
195
  return this.prepareOutput(fileInfo as unknown as CoreFileInfo, serviceOptions);
178
196
  }
@@ -537,6 +555,16 @@ export abstract class CoreFileService {
537
555
 
538
556
  /**
539
557
  * Get file stream (for big files) via filename
558
+ *
559
+ * Every branch resolves a DOCUMENT first and reads by its id — including the GridFS one, which
560
+ * used to fall through to `openDownloadStreamByName()`. That call defaults to `revision: -1` (the
561
+ * newest file of that name) while the by-name METADATA lookup answered the oldest, so with two
562
+ * files sharing a name this method streamed bytes belonging to a different document than the one
563
+ * `checkRights()` had just been asked about. An ownership rule then approved the caller's own file
564
+ * and handed over somebody else's — across tenants, since the file stores carry no tenant scope.
565
+ *
566
+ * `findFileByName()` is now newest-first in all three stores, so the bytes a caller receives are
567
+ * unchanged; what changed is that the document authorization inspected is the one being served.
540
568
  */
541
569
  async getFileStreamByName(filename: string, serviceOptions?: FileServiceOptions): Promise<Readable> {
542
570
  if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
@@ -550,7 +578,13 @@ export abstract class CoreFileService {
550
578
  if (fsFileInfo) {
551
579
  return FilesystemFileHelper.getStream(this.filesystemDir, fsFileInfo._id);
552
580
  }
553
- return GridFSHelper.openDownloadStreamByName(this.files, filename);
581
+ const gridFsInfo = await GridFSHelper.findFileByName(this.files, filename);
582
+ if (!gridFsInfo) {
583
+ // Unchanged answer for an unknown name: GridFS is the terminal store and reports the miss
584
+ // itself, asynchronously on the stream, which the controller turns into a 404.
585
+ return GridFSHelper.openDownloadStreamByName(this.files, filename);
586
+ }
587
+ return GridFSHelper.openDownloadStream(this.files, gridFsInfo._id);
554
588
  }
555
589
 
556
590
  /**
@@ -584,7 +618,14 @@ export abstract class CoreFileService {
584
618
  if (fsFileInfo) {
585
619
  return FilesystemFileHelper.getBuffer(this.filesystemDir, fsFileInfo._id);
586
620
  }
587
- return await GridFSHelper.readFileToBuffer(this.files, { filename });
621
+ // By id, for the same reason as getFileStreamByName(): reading by NAME here would pick the
622
+ // newest revision while checkRights() was asked about whichever document findFileByName()
623
+ // answered.
624
+ const gridFsInfo = await GridFSHelper.findFileByName(this.files, filename);
625
+ if (!gridFsInfo) {
626
+ return await GridFSHelper.readFileToBuffer(this.files, { filename });
627
+ }
628
+ return await GridFSHelper.readFileToBuffer(this.files, { _id: gridFsInfo._id });
588
629
  }
589
630
 
590
631
  /**
@@ -766,15 +807,43 @@ export abstract class CoreFileService {
766
807
  * `tests/file-ownership.e2e-spec.ts`. Prefer reading it there over copying
767
808
  * from here.
768
809
  *
810
+ * **Cover the `filterArgs` branch, and REFUSE it.** `findFileInfo()` consults this hook ONCE for the
811
+ * whole query, so there is no answer that means "…but only their own files". Returning `true` hands a
812
+ * caller a full inventory of every upload — `CoreFileInfo` carries `filename`, `length`, `uploadDate`
813
+ * and the `id`, and for medical data the filename frequently IS the content. Core exposes no listing
814
+ * endpoint, so this only bites once a project surfaces `findFileInfo()` — which is exactly when
815
+ * nobody re-reads the rule.
816
+ *
817
+ * A per-user listing is expressed by FORCING the constraint server-side:
818
+ *
819
+ * ```typescript
820
+ * this.fileService.findFileInfo(
821
+ * { filter: { singleFilter: { field: 'metadata.ownerId', operator: ComparisonOperatorEnum.EQ,
822
+ * value: String(currentUser.id) } } },
823
+ * { force: true },
824
+ * );
825
+ * ```
826
+ *
827
+ * Note what that is NOT: it does not inspect the caller's own `filterArgs` to check whether they are
828
+ * already narrowed. `filterArgs` is CLIENT-CONTROLLED, so approving a filter shape means validating
829
+ * attacker input, and any such check is one filter shape away from being wrong. **Override the
830
+ * filter; never approve it.**
831
+ *
769
832
  * @example
770
833
  * ```typescript
771
834
  * protected override async checkRights(
772
835
  * input: any,
773
836
  * options?: FileServiceOptions & { checkInputType: FileInputCheckType },
774
837
  * ): Promise<boolean> {
775
- * if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
838
+ * // Writes stay on the coarse gate: an upload has no owner to compare against yet.
839
+ * if (options?.force || options?.checkInputType === 'file' || options?.checkInputType === 'files') {
776
840
  * return true;
777
841
  * }
842
+ * // A LISTING cannot be narrowed by a yes/no hook — it is asked once for the whole query, not
843
+ * // once per row — so refuse the unrestricted one. See the note above.
844
+ * if (options?.checkInputType === 'filterArgs') {
845
+ * return false;
846
+ * }
778
847
  * if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
779
848
  * return true;
780
849
  * }
@@ -788,11 +857,99 @@ export abstract class CoreFileService {
788
857
  * }
789
858
  * ```
790
859
  */
860
+ /**
861
+ * The metadata a file is written with — the caller's, plus whatever the active preset decides on.
862
+ *
863
+ * A preset that only READ `metadata.ownerId` / `metadata.tenantId` would be a rule about data that
864
+ * does not exist: every file ADMIN-only. That is not hypothetical — it is exactly the shape TUS
865
+ * uploads had before 11.35.0, and the report came back from downstream rather than from a test.
866
+ *
867
+ * Three properties, each of which is a decision:
868
+ *
869
+ * - **Only under a preset that needs it.** With `file.access` unset (or `'public'` /
870
+ * `'authenticated'`) this returns `serviceOptions.metadata` untouched, so no existing project's
871
+ * documents grow a field.
872
+ * - **Never overrides what the caller supplied.** A project that records ownership itself — or
873
+ * deliberately attributes a file to someone else, as an admin provisioning flow does — keeps
874
+ * winning. `TUS_OWNER_METADATA_KEY` is the opposite case and overwrites on purpose, because there
875
+ * the value arrives from the CLIENT.
876
+ * - **Stamps nothing it cannot know.** An anonymous upload gets no owner, which leaves the file
877
+ * ADMIN-only rather than owned by `undefined`.
878
+ *
879
+ * `protected` so a project can add its own dimension (a project id, a case number) by overriding and
880
+ * calling `super`.
881
+ */
882
+ protected accessMetadata(serviceOptions?: FileServiceOptions): Record<string, any> | undefined {
883
+ const preset = resolveFileAccessPreset(ConfigService.configFastButReadOnly?.file);
884
+ if (preset !== 'owner' && preset !== 'tenant') {
885
+ return serviceOptions?.metadata;
886
+ }
887
+
888
+ const stamped: Record<string, any> = { ...serviceOptions?.metadata };
889
+ const ownerId = serviceOptions?.currentUser?.id;
890
+ if (stamped.ownerId === undefined && ownerId !== undefined && ownerId !== null && ownerId !== '') {
891
+ stamped.ownerId = String(ownerId);
892
+ }
893
+ if (preset === 'tenant') {
894
+ // The VALIDATED tenant, same source the read decision uses.
895
+ const tenantId = RequestContext.get()?.tenantId;
896
+ if (stamped.tenantId === undefined && tenantId) {
897
+ stamped.tenantId = tenantId;
898
+ }
899
+ }
900
+ return Object.keys(stamped).length ? stamped : undefined;
901
+ }
902
+
791
903
  protected checkRights(
792
- _input: any,
793
- _options?: FileServiceOptions & { checkInputType: FileInputCheckType },
904
+ input: any,
905
+ options?: FileServiceOptions & { checkInputType: FileInputCheckType },
794
906
  ): MaybePromise<boolean> {
795
- return true;
907
+ // `MaybePromise<boolean>`, NOT `Promise<boolean>`, and not `async`. Narrowing the declared return
908
+ // type would break every consumer whose override returns a plain `boolean` — which the old
909
+ // signature explicitly invited — and TypeScript rejects that at the OVERRIDE, in their code, with
910
+ // an error that points at their file rather than at this change. `async` forces `Promise<T>`, so
911
+ // the async work lives in a separate method instead.
912
+ return this.resolveAccessPreset(input, options);
913
+ }
914
+
915
+ /**
916
+ * The async half of {@link checkRights}, split out only so the public seam can keep its
917
+ * `MaybePromise<boolean>` signature (see there).
918
+ */
919
+ private async resolveAccessPreset(
920
+ input: any,
921
+ options?: FileServiceOptions & { checkInputType: FileInputCheckType },
922
+ ): Promise<boolean> {
923
+ const preset = resolveFileAccessPreset(ConfigService.configFastButReadOnly?.file);
924
+
925
+ // `'custom'` is the default and returns true for every input — byte-for-byte the pre-11.35.0
926
+ // behaviour, including the absence of any lookup. An existing project sees no change at all.
927
+ if (preset === 'custom') {
928
+ return true;
929
+ }
930
+
931
+ // The lookup is skipped where the decision cannot use it: a forced (system) call, a write, a
932
+ // listing, and the two blanket presets. So enabling a preset never adds a query to a path that
933
+ // does not read a document — which matters most for `force: true`, the idiom internal callers use
934
+ // on hot paths precisely because they have already been decided about.
935
+ const raw =
936
+ !options?.force && fileAccessNeedsRawDocument(preset, options?.checkInputType)
937
+ ? options?.checkInputType === 'id'
938
+ ? await this.getRawFileInfo(input)
939
+ : await this.getRawFileInfoByName(input)
940
+ : undefined;
941
+
942
+ return decideFileAccess({
943
+ checkInputType: options?.checkInputType,
944
+ currentUser: options?.currentUser,
945
+ force: options?.force,
946
+ preset,
947
+ raw,
948
+ // The VALIDATED tenant, from the same source `mongooseTenantPlugin` filters by — never a raw
949
+ // header, and never `serviceOptions`, so a file decision and a database decision cannot
950
+ // disagree about which tenant the request is in.
951
+ tenantId: RequestContext.get()?.tenantId,
952
+ });
796
953
  }
797
954
 
798
955
  /**
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The four project classes as ONE dial — `file.access`.
3
+ *
4
+ * WHY THIS EXISTS: the per-file rule is the only place a sentence like "…but only their own" can
5
+ * live, because that sentence needs data and no role name carries data. Until 11.35.0 the framework
6
+ * shipped that rule as an `@example` to copy, and the copy went wrong twice in this repository's own
7
+ * history — both times in the permissive direction:
8
+ *
9
+ * - `if (!options.currentUser) return true` reads as "system-internal call", and is also exactly what
10
+ * an ANONYMOUS request looks like;
11
+ * - narrowing only `'id'` / `'filename'` waves `'filterArgs'` through, which hands over a full
12
+ * inventory of every upload the moment a project surfaces `findFileInfo()`.
13
+ *
14
+ * Neither mistake is careless — both read as correct. That is the argument for a preset: the rule is
15
+ * always the same handful of sentences, so the framework should own them and the project should
16
+ * declare which one it wants.
17
+ *
18
+ * | `file.access` | project class |
19
+ * |-------------------|----------------------------------------------------------------|
20
+ * | `'custom'` | the project writes its own rule — **the default**; the framework abstains entirely |
21
+ * | `'public'` | open: anyone may read and write, the role gate is the whole policy |
22
+ * | `'authenticated'` | login-restricted: every signed-in user may use every file |
23
+ * | `'owner'` | per-user: only the uploader (plus ADMIN) |
24
+ * | `'tenant'` | per-tenant: only within one's own validated tenant (plus ADMIN) |
25
+ *
26
+ * NOTHING CHANGES FOR AN EXISTING PROJECT. `'custom'` is the default and returns `true` for every
27
+ * input, which is byte-for-byte what `CoreFileService.checkRights()` did before. A project that
28
+ * overrides `checkRights()` keeps full control regardless of the setting — the override IS the rule,
29
+ * and the preset never runs.
30
+ *
31
+ * THE DECISION IS A PURE FUNCTION. It takes an already-loaded raw document instead of a service, so
32
+ * every row of the table can be stated in a unit test without a database, and so the service's only
33
+ * remaining job is to decide whether a lookup is needed at all
34
+ * ({@link fileAccessNeedsRawDocument}) — a listing and a write never need one.
35
+ *
36
+ * DELIBERATELY A LEAF: it imports the role enum and nothing else. `file-roles.helper` already had to
37
+ * be split for the same reason (it imports the endpoint classes, which inject `CoreFileService`), and
38
+ * this file is read FROM the service. See `.claude/rules/architecture.md` → "DI Token Placement".
39
+ */
40
+ import { RoleEnum } from '../../common/enums/role.enum';
41
+
42
+ /** Which of the four project classes this deployment is. */
43
+ export type FileAccessPreset = 'authenticated' | 'custom' | 'owner' | 'public' | 'tenant';
44
+
45
+ /** The input types `CoreFileService.checkRights()` distinguishes. */
46
+ type CheckInputType = 'file' | 'filename' | 'files' | 'filterArgs' | 'id';
47
+
48
+ const PRESETS: readonly FileAccessPreset[] = ['authenticated', 'custom', 'owner', 'public', 'tenant'] as const;
49
+
50
+ /**
51
+ * Which preset a project asked for.
52
+ *
53
+ * An UNKNOWN value resolves to `'owner'`, the strictest of the data-driven presets — never to
54
+ * `'custom'`. A typo (`access: 'onwer'`) means somebody believes they have an ownership rule; giving
55
+ * them "the framework abstains" would confirm that belief and be wrong in the one direction that
56
+ * cannot be noticed from the outside. A too-strict answer surfaces as a 404 on the first request,
57
+ * which is a bug report; a too-permissive one surfaces as an incident.
58
+ *
59
+ * @default 'custom'
60
+ */
61
+ export function resolveFileAccessPreset(fileConfig?: { access?: FileAccessPreset }): FileAccessPreset {
62
+ const configured = fileConfig?.access;
63
+ if (configured === undefined || configured === null) {
64
+ return 'custom';
65
+ }
66
+ return PRESETS.includes(configured) ? configured : 'owner';
67
+ }
68
+
69
+ /**
70
+ * Does the decision for this (preset, input type) pair read the stored document?
71
+ *
72
+ * Asked by the service so it can skip the metadata lookup where the answer cannot use it. A write has
73
+ * no document yet, a listing is not about one document, and the two blanket presets answer without
74
+ * looking — loading anyway would add a query per call for nothing.
75
+ */
76
+ export function fileAccessNeedsRawDocument(preset: FileAccessPreset, checkInputType?: CheckInputType): boolean {
77
+ if (preset !== 'owner' && preset !== 'tenant') {
78
+ return false;
79
+ }
80
+ return checkInputType === 'id' || checkInputType === 'filename';
81
+ }
82
+
83
+ /**
84
+ * Decide one file access question.
85
+ *
86
+ * @param options.raw the stored metadata document, loaded by the caller when
87
+ * {@link fileAccessNeedsRawDocument} says so. `null` means "no such file", which must not be
88
+ * confused with "no restriction".
89
+ * @param options.tenantId the VALIDATED tenant of the current request (`RequestContext.tenantId`),
90
+ * never a raw header — an unvalidated header would be worse than no tenant at all.
91
+ */
92
+ export function decideFileAccess(options: {
93
+ checkInputType?: CheckInputType;
94
+ currentUser?: { hasRole?: (roles: string[]) => boolean; id?: any };
95
+ force?: boolean;
96
+ preset: FileAccessPreset;
97
+ raw?: null | Record<string, any>;
98
+ tenantId?: string;
99
+ }): boolean {
100
+ const { checkInputType, currentUser, force, preset, raw, tenantId } = options;
101
+
102
+ // A system caller says so explicitly. This is the documented alternative to the
103
+ // `if (!currentUser) return true` shortcut: the exemption is stated at the call site, in the diff,
104
+ // rather than inferred from an absence that an anonymous request produces too.
105
+ if (force) {
106
+ return true;
107
+ }
108
+
109
+ // The framework abstains — identical to the pre-11.35.0 base implementation.
110
+ if (preset === 'custom' || preset === 'public') {
111
+ return true;
112
+ }
113
+
114
+ if (preset === 'authenticated') {
115
+ // Deliberately independent of the role gate: it still holds when `downloadRoles` is `S_EVERYONE`,
116
+ // which is the case where a project believes the login requirement is somewhere else.
117
+ return !!currentUser?.id;
118
+ }
119
+
120
+ // ADMIN is platform authority and is never locked out — the same union the role guards apply.
121
+ if (currentUser?.hasRole?.([RoleEnum.ADMIN])) {
122
+ return true;
123
+ }
124
+
125
+ // Writes stay on the coarse role gate (`file.uploadRoles` / `deleteRoles`): an upload has no stored
126
+ // document yet, so there is nothing to compare against. Ownership of the RESULT is established by
127
+ // the metadata the service stamps as it writes.
128
+ if (checkInputType === 'file' || checkInputType === 'files') {
129
+ return true;
130
+ }
131
+
132
+ // A LISTING cannot be narrowed by a yes/no answer — this hook is asked once for the whole query,
133
+ // not once per row. Refusing is the only honest answer; a per-user listing is expressed by forcing
134
+ // the filter server-side and passing `force: true`. See CoreFileService.checkRights().
135
+ if (checkInputType === 'filterArgs') {
136
+ return false;
137
+ }
138
+
139
+ if (preset === 'owner') {
140
+ // Requiring the field to be PRESENT is load-bearing twice over: without it an owner-less file
141
+ // compares `String(undefined)` with `String(undefined)` and matches, so every unowned file would
142
+ // be readable by every caller whose id is also missing — i.e. by anonymous requests.
143
+ return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(currentUser?.id);
144
+ }
145
+
146
+ // 'tenant': both sides must be present. No tenant in context is a cron job on the HTTP path and an
147
+ // unresolvable header on the WebSocket path; neither may read tenant-scoped bytes.
148
+ return !!tenantId && !!raw?.metadata?.tenantId && String(raw.metadata.tenantId) === String(tenantId);
149
+ }
@@ -74,17 +74,25 @@ export async function findMetadataById(
74
74
  }
75
75
 
76
76
  /**
77
- * Find one metadata document by filename.
77
+ * Find one metadata document by filename — the MOST RECENT file with that name.
78
78
  *
79
- * Resolves the FIRST match filenames are not unique in any of these stores, and
80
- * are client-supplied on both the multer and the tus path. Prefer the id lookup
81
- * wherever the caller has an id.
79
+ * Filenames are unique in NO store and are client-supplied on both the multer and the tus path, so
80
+ * a by-name lookup is inherently ambiguous: prefer the id lookup wherever the caller has an id.
81
+ * What must not be ambiguous is WHICH of the candidates every by-name path picks — an ownership
82
+ * rule reads one document and the download must serve that same one.
83
+ *
84
+ * A bare `findOne({ filename })` returns natural order, which is neither documented nor stable
85
+ * under compaction. Newest-first matches GridFS's own by-name revision semantics
86
+ * (`openDownloadStreamByName` defaults to `revision: -1`), so all three storage drivers answer the
87
+ * same question the same way; `_id` breaks the tie for two files written in one millisecond.
82
88
  */
83
89
  export async function findMetadataByName(
84
90
  collection: FileCollection,
85
91
  filename: string,
86
92
  ): Promise<FileMetadataInfo | null> {
87
- return (await collection.findOne({ filename })) as FileMetadataInfo | null;
93
+ // `uploadDate` FIRST MongoDB applies sort keys in document order, so leading with `_id` would
94
+ // make the tie-break the primary key.
95
+ return (await collection.findOne({ filename }, { sort: { uploadDate: -1, _id: -1 } })) as FileMetadataInfo | null;
88
96
  }
89
97
 
90
98
  /** Find metadata documents by filter */
@@ -0,0 +1,202 @@
1
+ /**
2
+ * File-role CONFIGURATION and the two boot warnings — the half that touches no endpoint class.
3
+ *
4
+ * SPLIT OUT OF `file-roles.helper.ts` because that file imports `CoreFileController` and
5
+ * `CoreFileResolver` (it writes role metadata onto their prototypes), and both of them inject
6
+ * `CoreFileService`. So a service reading a warning from there closes
7
+ *
8
+ * core-file.service -> file-roles.helper -> core-file.controller -> core-file.service
9
+ *
10
+ * and the controller dereferences `CoreFileService` in `design:paramtypes`, i.e. at CLASS-DEFINITION
11
+ * time. That is the fatal shape: SWC -> CommonJS dies at startup with
12
+ * `Cannot access 'CoreFileService' before initialization`, while tsc, vitest and oxlint all stay
13
+ * green. `pnpm run check:swc-tdz` caught exactly that when the warning was first added here.
14
+ *
15
+ * This file therefore imports only enums and interfaces. See
16
+ * `.claude/rules/architecture.md` -> "DI Token Placement (SWC-Safe)".
17
+ */
18
+ import { Logger } from '@nestjs/common';
19
+
20
+ import { RoleEnum } from '../../common/enums/role.enum';
21
+ import { IFileConfig, IS3Config } from '../../common/interfaces/server-options.interface';
22
+
23
+ const logger = new Logger('CoreFileRoles');
24
+
25
+ /**
26
+ * Roles applied when `file` is not configured at all.
27
+ *
28
+ * Restrictive on purpose: one GridFS bucket is shared by every feature of the
29
+ * consuming project, and the ObjectIds naming its blobs are not secrets.
30
+ */
31
+ export type FileRoleKey = 'deleteRoles' | 'downloadRoles' | 'uploadRoles';
32
+
33
+ export const FILE_ROLE_DEFAULTS: Record<FileRoleKey, string[]> = {
34
+ deleteRoles: [RoleEnum.ADMIN],
35
+ downloadRoles: [RoleEnum.ADMIN],
36
+ uploadRoles: [RoleEnum.ADMIN],
37
+ };
38
+
39
+ /**
40
+ * Resolve one knob to the role list that will actually be applied.
41
+ *
42
+ * An empty array is treated as "not configured". It cannot mean "nobody": the
43
+ * guards read an all-empty role set as "no roles required" and return true, so
44
+ * honouring it literally would OPEN the route instead of closing it — the exact
45
+ * opposite of what someone writing `[]` intends.
46
+ */
47
+ export function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
48
+ const configured = config?.[key];
49
+
50
+ if (configured === undefined) {
51
+ return FILE_ROLE_DEFAULTS[key];
52
+ }
53
+
54
+ if (!Array.isArray(configured) || configured.length === 0 || configured.some((role) => typeof role !== 'string')) {
55
+ logger.warn(
56
+ `Ignoring file.${key}: expected a non-empty array of role strings, got ${JSON.stringify(configured)}. ` +
57
+ `Falling back to ${JSON.stringify(FILE_ROLE_DEFAULTS[key])}.`,
58
+ );
59
+ return FILE_ROLE_DEFAULTS[key];
60
+ }
61
+
62
+ return configured;
63
+ }
64
+
65
+ /**
66
+ * Warn when presigned S3 downloads are combined with a restricted `downloadRoles`.
67
+ *
68
+ * The two settings pull in opposite directions, and the conflict is invisible at the call site:
69
+ *
70
+ * - `downloadRoles` says "only these roles may download this file". It is enforced on every request.
71
+ * - `presignedDownloads` answers `302` with a time-limited S3 URL instead of streaming. That URL is
72
+ * a BEARER capability — authorized once, at issue time. Whoever holds it afterwards fetches the
73
+ * object with no session, from any IP, until it expires, and **the grant cannot be revoked in
74
+ * between**. It survives in browser history, `Referer` headers, proxy logs and chat messages.
75
+ *
76
+ * So the second setting hands out exactly what the first one restricts. That is a sound trade for
77
+ * public assets (which is what presigning is FOR — hence no warning when downloads are
78
+ * `S_EVERYONE`), and almost never what a project means when it has narrowed the roles.
79
+ *
80
+ * A warning rather than a boot failure: unlike an incoherent role vocabulary, this combination has
81
+ * legitimate uses (short expiry, a CDN in front, files whose audience really is "anyone who once
82
+ * held the link"). The operator has to be able to choose it — they just should not choose it by
83
+ * accident.
84
+ */
85
+ export function warnOnPresignedDownloadsWithRestrictedRoles(
86
+ s3Config?: IS3Config,
87
+ fileConfig?: IFileConfig,
88
+ ): string | undefined {
89
+ const presigned = s3Config?.presignedDownloads;
90
+ const presignedEnabled =
91
+ presigned === true || (!!presigned && typeof presigned === 'object' && (presigned as any).enabled !== false);
92
+ if (!presignedEnabled) {
93
+ return undefined;
94
+ }
95
+
96
+ const downloadRoles = resolveRoles('downloadRoles', fileConfig);
97
+ // Public downloads are the intended use of presigning — nothing to warn about.
98
+ if (downloadRoles.includes(RoleEnum.S_EVERYONE)) {
99
+ return undefined;
100
+ }
101
+
102
+ // Returned as well as logged: the message IS the contract here (an operator has to be able to act
103
+ // on it), and a module-private Logger instance cannot be asserted against from a unit test.
104
+ const message =
105
+ `s3.presignedDownloads is enabled while file.downloadRoles restricts downloads to ` +
106
+ `${JSON.stringify(downloadRoles)}. A presigned URL is a BEARER capability: it is authorized ` +
107
+ `once, at issue time, and afterwards anyone holding it can fetch the object with no session, ` +
108
+ `from any IP, until it expires — the grant cannot be revoked in between, and it survives in ` +
109
+ `browser history, Referer headers and proxy logs. The role check therefore applies to ` +
110
+ `obtaining the link, not to reading the file. If these files are sensitive (personal or ` +
111
+ `medical data), set s3.presignedDownloads: false so the API streams them and re-checks ` +
112
+ `rights on every request.`;
113
+
114
+ logger.warn(message);
115
+ return message;
116
+ }
117
+
118
+ /**
119
+ * Warn when the file gate is open and NOTHING decides the per-file policy.
120
+ *
121
+ * THE GAP: the role knobs are a coarse audience filter — they answer "may this caller reach the route
122
+ * at all". They cannot express "…but only their own", because that sentence needs data. So a deployment
123
+ * that widened the gate past platform admins and expressed no per-file policy anywhere has a store
124
+ * every holder of that role can read in full.
125
+ *
126
+ * And that is practically reachable, not theoretically: file ids are ENUMERABLE. An ObjectId is 4 bytes
127
+ * of timestamp + 5 bytes of randomness generated once PER PROCESS + a 3-byte incrementing counter, so a
128
+ * caller who obtains one valid id — their own upload — knows the random part and a counter reference
129
+ * point, and neighbouring files sit on neighbouring values. Nothing rate-limits the file routes either.
130
+ *
131
+ * WHY THE CONDITIONS ARE THIS NARROW — a warning that fires on a correct configuration gets muted, and
132
+ * a muted warning is worse than none. So every way of DECIDING silences it, and all three are
133
+ * legitimate:
134
+ *
135
+ * 1. `file.access` names a project class (`'public'`, `'authenticated'`, `'owner'`, `'tenant'`);
136
+ * 2. `checkRights()` is overridden — the project wrote its own rule, and grading it is beyond what a
137
+ * boot check can do;
138
+ * 3. the gate is still admin-only — a platform admin legitimately sees everything.
139
+ *
140
+ * That leaves exactly one case: the gate is open and nothing says what the policy is. This warning is
141
+ * about the difference between a DECISION and an OMISSION, which is the only thing a boot check can
142
+ * usefully detect.
143
+ *
144
+ * Multi-tenancy changes only the WORDING. An earlier version of this warning fired only for tenant
145
+ * projects, which was too narrow: `downloadRoles: [S_USER]` with no rule leaks every file to every
146
+ * signed-in user whether or not tenants exist. Where tenants DO exist, the leak also crosses that
147
+ * boundary, and that sentence has to appear — the file stores are reached outside Mongoose, so
148
+ * `mongooseTenantPlugin` never scopes them and these role names resolve against `user.roles`, a GLOBAL
149
+ * attribute.
150
+ *
151
+ * A warning, not a boot failure. The framework cannot know whether the files are patient documents or
152
+ * public logos, and refusing to start on a configuration that is correct for the second would be
153
+ * wrong. What it can do is refuse to be silent.
154
+ *
155
+ * @param hasPerFileRule whether `CoreFileService.checkRights()` is overridden — the caller knows,
156
+ * because it has the instance; this helper stays a pure function so it can be unit-tested.
157
+ * @returns the message, or `undefined` when there is nothing to warn about. Returned as well as
158
+ * logged for the same reason as {@link warnOnPresignedDownloadsWithRestrictedRoles}: the message IS
159
+ * the contract, and a module-private Logger cannot be asserted against.
160
+ */
161
+ export function warnOnUndecidedFileAccess(options: {
162
+ fileConfig?: IFileConfig;
163
+ hasPerFileRule: boolean;
164
+ multiTenancyEnabled: boolean;
165
+ }): string | undefined {
166
+ const { fileConfig, hasPerFileRule, multiTenancyEnabled } = options;
167
+
168
+ // (1) and (2): somebody decided.
169
+ if (hasPerFileRule || (fileConfig?.access && fileConfig.access !== 'custom')) {
170
+ return undefined;
171
+ }
172
+
173
+ // (3): admin-only. ADMIN is unioned in by the class-level decorator regardless, so it never widens.
174
+ const widened: string[] = [];
175
+ for (const key of ['deleteRoles', 'downloadRoles', 'uploadRoles'] as FileRoleKey[]) {
176
+ const roles = resolveRoles(key, fileConfig);
177
+ if (roles.some((role) => role !== RoleEnum.ADMIN)) {
178
+ widened.push(`file.${key}=${JSON.stringify(roles)}`);
179
+ }
180
+ }
181
+ if (!widened.length) {
182
+ return undefined;
183
+ }
184
+
185
+ const tenantNote = multiTenancyEnabled
186
+ ? ' multiTenancy is active, and the leak crosses tenants too: the file stores are reached outside ' +
187
+ 'Mongoose, so mongooseTenantPlugin never scopes them and these role names resolve against ' +
188
+ 'user.roles — a GLOBAL attribute — never against membership.role.'
189
+ : '';
190
+
191
+ const message =
192
+ `The file gate is open beyond platform admins (${widened.join(', ')}), but no per-file policy is ` +
193
+ `declared: file.access is unset and CoreFileService.checkRights() is not overridden. Every holder ` +
194
+ `of such a role can therefore read, overwrite or delete EVERY file — and file ids are not secret, ` +
195
+ `they are ENUMERABLE (an ObjectId shares a per-process random part and an incrementing counter, so ` +
196
+ `one own upload reveals the neighbourhood), with no rate limit on the file routes.${tenantNote} ` +
197
+ `Declare the project class with file.access ('public' | 'authenticated' | 'owner' | 'tenant'), or ` +
198
+ `override checkRights() — see src/core/modules/file/README.md § Access control.`;
199
+
200
+ logger.warn(message);
201
+ return message;
202
+ }