@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
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Optional }
2
2
  import { InjectModel } from '@nestjs/mongoose';
3
3
  import { Model } from 'mongoose';
4
4
 
5
+ import { looksLikeGlobalOnlyRole, looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
5
6
  import { ConfigService } from '../../common/services/config.service';
6
7
  import { RequestContext } from '../../common/services/request-context.service';
7
8
  import { CoreTenantMemberModel } from './core-tenant-member.model';
@@ -101,6 +102,7 @@ export class CoreTenantService {
101
102
  throw new BadRequestException('userId must not be empty');
102
103
  }
103
104
  const effectiveRole = role ?? this.getDefaultRole();
105
+ assertAssignableMembershipRole(effectiveRole);
104
106
 
105
107
  // Check for existing membership
106
108
  const existing = await this.getMembership(tenantId, userId);
@@ -188,6 +190,7 @@ export class CoreTenantService {
188
190
  if (!role?.trim()) {
189
191
  throw new BadRequestException('role must not be empty');
190
192
  }
193
+ assertAssignableMembershipRole(role);
191
194
  const highestRole = this.getHighestRole();
192
195
 
193
196
  // If demoting from highest role, ensure it's not the last one
@@ -242,3 +245,56 @@ export class CoreTenantService {
242
245
  });
243
246
  }
244
247
  }
248
+
249
+ /**
250
+ * Refuse a membership role that would cross the tenant boundary.
251
+ *
252
+ * Membership roles are customer-assigned free text, and whoever may manage members is typically a
253
+ * tenant owner — a customer. Two families of name must never become one:
254
+ *
255
+ * - **system roles** (`s_*`) are runtime-context questions ("is this the owner of the record?"),
256
+ * not stored roles. A membership named `s_self` used to satisfy `@Restricted(S_SELF)` on
257
+ * arbitrary records.
258
+ * - **global-only roles** (`RoleEnum.ADMIN`) are platform authority. A membership named `admin`
259
+ * used to satisfy `@Roles(RoleEnum.ADMIN)` — the global role — inside tenant context.
260
+ *
261
+ * This is the SECOND layer, not the protection itself. The guards resolve each required role
262
+ * against its own source (`user.roles` vs `membership.role`), so an already-stored dangerous name
263
+ * is inert even without this check — which matters, because a future `RoleEnum` addition would
264
+ * otherwise turn every pre-existing membership of that name into a hole retroactively. This check
265
+ * only stops new ones from being created, and gives a clear error instead of silent inertness.
266
+ */
267
+ export function assertAssignableMembershipRole(role: string): void {
268
+ if (looksLikeSystemRole(role)) {
269
+ throw new BadRequestException(
270
+ `A system role (${SYSTEM_ROLE_PREFIX}*) must never be used as a tenant membership role: ${role}`,
271
+ );
272
+ }
273
+ if (looksLikeGlobalOnlyRole(role)) {
274
+ throw new BadRequestException(
275
+ `"${role}" is a global role and must never be used as a tenant membership role — ` +
276
+ 'use a tenant-specific name such as "tenantAdmin" instead',
277
+ );
278
+ }
279
+
280
+ // Deny by default, when enabled: only roles the project actually declared.
281
+ //
282
+ // An undeclared role can never GRANT anything either way — the guards match only declared tenant
283
+ // roles — so this does not change access decisions. What it changes is WHEN the mistake surfaces:
284
+ // as a 400 at assignment time, instead of as a membership that silently authorizes nothing while
285
+ // looking perfectly fine in a members list.
286
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
287
+ if (config?.strictMembershipRoles) {
288
+ const declared = new Set([
289
+ ...Object.keys(config.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY),
290
+ ...(config.additionalMembershipRoles ?? []),
291
+ ]);
292
+ if (!declared.has(role)) {
293
+ throw new BadRequestException(
294
+ `"${role}" is not a declared tenant role. Declared: [${[...declared].sort().join(', ')}]. ` +
295
+ 'Add it to multiTenancy.roleHierarchy or multiTenancy.additionalMembershipRoles, ' +
296
+ 'or disable multiTenancy.strictMembershipRoles.',
297
+ );
298
+ }
299
+ }
300
+ }
@@ -380,13 +380,52 @@ query {
380
380
 
381
381
  The following metadata is stored with each GridFS file:
382
382
 
383
- | Field | Source |
384
- | ------------------ | --------------------------------- |
385
- | `filename` | From TUS `Upload-Metadata` header |
386
- | `contentType` | From TUS `filetype` metadata |
387
- | `tusUploadId` | Original TUS upload ID |
388
- | `originalMetadata` | All TUS metadata |
389
- | `uploadedAt` | Completion timestamp |
383
+ | Field | Source |
384
+ | ------------------ | ------------------------------------- |
385
+ | `filename` | From TUS `Upload-Metadata` header |
386
+ | `contentType` | From TUS `filetype` metadata |
387
+ | `tusUploadId` | Original TUS upload ID |
388
+ | `originalMetadata` | All TUS metadata |
389
+ | `ownerId` | The authenticated uploader (11.35.0+) |
390
+ | `uploadedAt` | Completion timestamp |
391
+
392
+ ### Upload ownership (11.35.0+)
393
+
394
+ `tus.roles` decides **who may reach the endpoint**. It says nothing about **which upload** a caller may
395
+ touch — and the protocol is built around a per-upload URL: after `POST /tus` the client holds
396
+ `/tus/<id>` and uses it for `HEAD` (offset), `PATCH` (append bytes) and `DELETE` (terminate). Until
397
+ 11.35.0 all three carried only that coarse gate, so any other authenticated caller who learned an id
398
+ could resume, **overwrite** or destroy somebody else's upload. Overwriting is the sharp end: the bytes
399
+ are migrated into the file store under the ORIGINAL uploader's filename.
400
+
401
+ Two things changed:
402
+
403
+ - **`onUploadCreate` records the creator** in the upload's own metadata under
404
+ `TUS_OWNER_METADATA_KEY` (`ltOwnerId`). It **overwrites** any client-supplied value — metadata
405
+ arrives in the `Upload-Metadata` header, so a merged value would let a caller name somebody else as
406
+ the owner.
407
+ - **`onIncomingRequest` refuses a request naming an upload the caller does not own**, with **404** — the
408
+ same "a refusal is indistinguishable from a missing resource" policy the file module uses, so the
409
+ endpoint is not an existence oracle for upload ids.
410
+
411
+ The finished file's metadata gains `ownerId`, which is the key
412
+ `CoreFileService.checkRights()` documents — so a tus-uploaded file can finally satisfy a per-file
413
+ ownership rule. Before this it could not: the rule failed closed for everyone but ADMIN, and a project
414
+ following the documented pattern ended up with files nobody could download.
415
+
416
+ **An owner-LESS upload stays reachable by anyone who may reach the endpoint.** Deliberately: uploads
417
+ created before 11.35.0 carry no owner, and neither does an intentionally public form
418
+ (`tus.roles: [RoleEnum.S_EVERYONE]`). Denying those would break in-flight uploads on upgrade and a
419
+ documented configuration. What is closed is an upload that HAS an owner being touched by somebody else.
420
+
421
+ Both `readRequestUserId()` and `assertUploadOwnership()` are `protected` — override to read the owner
422
+ from elsewhere (an API key, a signed form token), or to let a support role resume any upload.
423
+
424
+ > **Note for a custom service:** `@tus/server` v2 does NOT hand the Express request to its hooks. It
425
+ > converts the Node request into a WHATWG `ServerRequest` first, so anything a guard attached lives on
426
+ > the original request, reachable through `runtime.node.req` — which is why `readRequestUserId()` checks
427
+ > there as well. Reading only `req.user` finds nothing and every upload silently becomes owner-less,
428
+ > failing in the permissive direction.
390
429
 
391
430
  ---
392
431
 
@@ -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
 
@@ -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,7 @@ 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 { applyFileRoles, warnOnPresignedDownloadsWithRestrictedRoles } from './core/modules/file/file-roles.helper';
50
55
  import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
51
56
  import { CoreHubModule } from './core/modules/hub/core-hub.module';
52
57
  import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
@@ -253,6 +258,12 @@ export class CoreModule implements NestModule {
253
258
  if (config.security?.mongooseRoleGuardPlugin !== false) {
254
259
  connection.plugin(mongooseRoleGuardPlugin);
255
260
  }
261
+ // Refuse to store system roles (s_*) in a roles array. Deliberately NOT configurable and
262
+ // registered independently of the role guard above: that one decides WHO may change roles
263
+ // (and can be switched off), this one decides WHICH values may exist at all. A stored
264
+ // 's_self' satisfies every S_SELF check on arbitrary users, so there is no configuration
265
+ // under which writing one is legitimate.
266
+ connection.plugin(mongooseSystemRolePlugin);
256
267
  // Add audit fields plugin (enabled by default, opt-out via config)
257
268
  if (config.security?.mongooseAuditFieldsPlugin !== false) {
258
269
  connection.plugin(mongooseAuditFieldsPlugin);
@@ -273,6 +284,11 @@ export class CoreModule implements NestModule {
273
284
  // function and opt out of this; see applyFileRoles() for why.
274
285
  applyFileRoles(config.file);
275
286
 
287
+ // Presigned downloads hand out a bearer URL that ignores roles after it is issued, so pairing
288
+ // them with a restricted downloadRoles is usually an oversight. Warn rather than fail — the
289
+ // combination has legitimate uses, it just should not be reached by accident.
290
+ warnOnPresignedDownloadsWithRestrictedRoles(config.s3, config.file);
291
+
276
292
  // Check secrets
277
293
  const jwtConfig = config.jwt;
278
294
  if (jwtConfig?.secret && jwtConfig.secret && jwtConfig.refresh && jwtConfig.refresh.secret === jwtConfig.secret) {
@@ -593,6 +609,21 @@ export class CoreModule implements NestModule {
593
609
  cors,
594
610
  installSubscriptionHandlers: true,
595
611
  subscriptions: {
612
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
613
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
614
+ // are the only place where a whole WS operation is reachable as one call. They must sit
615
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
616
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
617
+ // silently dropped.
618
+ //
619
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
620
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
621
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
622
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
623
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
624
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
625
+ execute: buildRequestContextAwareExecute(),
626
+ subscribe: buildRequestContextAwareSubscribe(),
596
627
  'graphql-ws': {
597
628
  context: ({ extra }) => extra,
598
629
  onConnect: async (context: Context<any, any>) => {
@@ -688,6 +719,21 @@ export class CoreModule implements NestModule {
688
719
  cors,
689
720
  installSubscriptionHandlers: true,
690
721
  subscriptions: {
722
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
723
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
724
+ // are the only place where a whole WS operation is reachable as one call. They must sit
725
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
726
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
727
+ // silently dropped.
728
+ //
729
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
730
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
731
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
732
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
733
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
734
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
735
+ execute: buildRequestContextAwareExecute(),
736
+ subscribe: buildRequestContextAwareSubscribe(),
691
737
  'graphql-ws': {
692
738
  context: ({ extra }) => extra,
693
739
  onConnect: async (context: Context<any, any>) => {
@@ -800,6 +846,21 @@ export class CoreModule implements NestModule {
800
846
  cors,
801
847
  installSubscriptionHandlers: true,
802
848
  subscriptions: {
849
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
850
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
851
+ // are the only place where a whole WS operation is reachable as one call. They must sit
852
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
853
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
854
+ // silently dropped.
855
+ //
856
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
857
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
858
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
859
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
860
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
861
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
862
+ execute: buildRequestContextAwareExecute(),
863
+ subscribe: buildRequestContextAwareSubscribe(),
803
864
  'graphql-ws': {
804
865
  context: ({ extra }) => extra,
805
866
  onConnect: async (context: Context<any, any>) => {
package/src/index.ts CHANGED
@@ -81,6 +81,7 @@ export * from './core/common/plugins/mongoose-id.plugin';
81
81
  export * from './core/common/plugins/mongoose-audit-fields.plugin';
82
82
  export * from './core/common/plugins/mongoose-password.plugin';
83
83
  export * from './core/common/plugins/mongoose-role-guard.plugin';
84
+ export * from './core/common/plugins/mongoose-system-role.plugin';
84
85
  export * from './core/common/plugins/mongoose-tenant.plugin';
85
86
  export * from './core/common/scalars/any.scalar';
86
87
  export * from './core/common/scalars/date-timestamp.scalar';
@@ -176,6 +177,7 @@ export * from './core/modules/file/core-file-info.model';
176
177
  export * from './core/modules/file/core-file.controller';
177
178
  export * from './core/modules/file/core-file.resolver';
178
179
  export * from './core/modules/file/core-file.service';
180
+ export * from './core/modules/file/file-access.helper';
179
181
  export * from './core/modules/file/file-roles.helper';
180
182
  export * from './core/modules/file/file-storage.helper';
181
183
  export * from './core/modules/file/filesystem-file.helper';
@@ -236,6 +238,7 @@ export * from './core/modules/tenant/core-tenant-member.model';
236
238
  export * from './core/modules/tenant/core-tenant.decorators';
237
239
  export * from './core/modules/tenant/core-tenant.enums';
238
240
  export * from './core/modules/tenant/core-tenant.guard';
241
+ export * from './core/modules/tenant/core-role-scope.registry';
239
242
  export * from './core/modules/tenant/core-tenant.helpers';
240
243
  export * from './core/modules/tenant/core-tenant.module';
241
244
  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 =