@lenne.tech/nest-server 11.34.0 → 11.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude/rules/architecture.md +3 -1
  2. package/.claude/rules/configurable-features.md +3 -3
  3. package/.claude/rules/role-system.md +281 -0
  4. package/CLAUDE.md +11 -1
  5. package/FRAMEWORK-API.md +7 -3
  6. package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
  7. package/dist/core/common/decorators/nested-type.registry.js +17 -0
  8. package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
  9. package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
  10. package/dist/core/common/decorators/restricted.decorator.js +52 -16
  11. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  12. package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
  13. package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
  14. package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
  15. package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
  16. package/dist/core/common/decorators/unified-field.decorator.js +6 -3
  17. package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
  18. package/dist/core/common/enums/role.enum.d.ts +8 -0
  19. package/dist/core/common/enums/role.enum.js +28 -1
  20. package/dist/core/common/enums/role.enum.js.map +1 -1
  21. package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
  22. package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
  23. package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
  24. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
  25. package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
  26. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
  27. package/dist/core/common/helpers/gridfs.helper.js +1 -1
  28. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  29. package/dist/core/common/helpers/validation-message.helper.d.ts +3 -0
  30. package/dist/core/common/helpers/validation-message.helper.js +41 -0
  31. package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
  32. package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
  33. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  34. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  35. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  36. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  37. package/dist/core/common/pipes/map-and-validate.pipe.js +16 -6
  38. package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
  39. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  40. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  41. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  42. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  43. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  44. package/dist/core/common/services/core-s3.service.js +4 -1
  45. package/dist/core/common/services/core-s3.service.js.map +1 -1
  46. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  47. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  48. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  49. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  50. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  51. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  52. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  53. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  54. package/dist/core/modules/file/core-file.service.d.ts +3 -1
  55. package/dist/core/modules/file/core-file.service.js +62 -7
  56. package/dist/core/modules/file/core-file.service.js.map +1 -1
  57. package/dist/core/modules/file/file-access.helper.d.ts +18 -0
  58. package/dist/core/modules/file/file-access.helper.js +46 -0
  59. package/dist/core/modules/file/file-access.helper.js.map +1 -0
  60. package/dist/core/modules/file/file-metadata.helper.js +1 -1
  61. package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
  62. package/dist/core/modules/file/file-roles.config.d.ts +10 -0
  63. package/dist/core/modules/file/file-roles.config.js +78 -0
  64. package/dist/core/modules/file/file-roles.config.js.map +1 -0
  65. package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
  66. package/dist/core/modules/file/file-roles.helper.js +8 -20
  67. package/dist/core/modules/file/file-roles.helper.js.map +1 -1
  68. package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
  69. package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
  70. package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
  71. package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
  72. package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
  73. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  74. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
  75. package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
  76. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  77. package/dist/core/modules/tenant/core-tenant.module.js +5 -0
  78. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  79. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  80. package/dist/core/modules/tenant/core-tenant.service.js +25 -0
  81. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  82. package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
  83. package/dist/core/modules/tus/core-tus.service.js +34 -4
  84. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  85. package/dist/core/modules/tus/index.d.ts +1 -0
  86. package/dist/core/modules/tus/index.js +1 -0
  87. package/dist/core/modules/tus/index.js.map +1 -1
  88. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  89. package/dist/core/modules/tus/tus.constants.js +2 -1
  90. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  91. package/dist/core/modules/user/core-user.service.js +5 -0
  92. package/dist/core/modules/user/core-user.service.js.map +1 -1
  93. package/dist/core/modules/user/inputs/core-user.input.js +8 -0
  94. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  95. package/dist/core.module.js +10 -0
  96. package/dist/core.module.js.map +1 -1
  97. package/dist/index.d.ts +3 -0
  98. package/dist/index.js +3 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/server/modules/file/file.service.js +4 -1
  101. package/dist/server/modules/file/file.service.js.map +1 -1
  102. package/dist/test/test.helper.d.ts +1 -0
  103. package/dist/test/test.helper.js +26 -6
  104. package/dist/test/test.helper.js.map +1 -1
  105. package/dist/tsconfig.build.tsbuildinfo +1 -1
  106. package/docs/REQUEST-LIFECYCLE.md +60 -1
  107. package/migration-guides/11.34.0-to-11.34.1.md +132 -0
  108. package/migration-guides/11.34.x-to-11.35.x.md +832 -0
  109. package/package.json +1 -1
  110. package/src/core/common/decorators/nested-type.registry.ts +42 -0
  111. package/src/core/common/decorators/restricted.decorator.ts +149 -21
  112. package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
  113. package/src/core/common/decorators/unified-field.decorator.ts +4 -3
  114. package/src/core/common/enums/role.enum.ts +116 -0
  115. package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
  116. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  117. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  118. package/src/core/common/helpers/validation-message.helper.ts +83 -0
  119. package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
  120. package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
  121. package/src/core/common/interfaces/server-options.interface.ts +139 -7
  122. package/src/core/common/pipes/map-and-validate.pipe.ts +25 -7
  123. package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
  124. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  125. package/src/core/common/services/core-s3.service.ts +9 -1
  126. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  127. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  128. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  129. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  130. package/src/core/modules/file/README.md +61 -0
  131. package/src/core/modules/file/core-file.service.ts +166 -9
  132. package/src/core/modules/file/file-access.helper.ts +149 -0
  133. package/src/core/modules/file/file-metadata.helper.ts +13 -5
  134. package/src/core/modules/file/file-roles.config.ts +202 -0
  135. package/src/core/modules/file/file-roles.helper.ts +11 -40
  136. package/src/core/modules/tenant/README.md +20 -1
  137. package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
  138. package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
  139. package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
  140. package/src/core/modules/tenant/core-tenant.module.ts +12 -0
  141. package/src/core/modules/tenant/core-tenant.service.ts +56 -0
  142. package/src/core/modules/tus/README.md +46 -7
  143. package/src/core/modules/tus/core-tus.service.ts +94 -5
  144. package/src/core/modules/tus/index.ts +1 -0
  145. package/src/core/modules/tus/tus.constants.ts +17 -1
  146. package/src/core/modules/user/core-user.service.ts +12 -0
  147. package/src/core/modules/user/inputs/core-user.input.ts +28 -2
  148. package/src/core.module.ts +62 -1
  149. package/src/index.ts +3 -0
  150. package/src/server/modules/file/file.service.ts +45 -2
  151. package/src/test/test.helper.ts +54 -11
@@ -1,8 +1,8 @@
1
+ import { isForbiddenMembershipRole, isGlobalOnlyRole, looksLikeSystemRole } from '../../common/enums/role.enum';
1
2
  import { ConfigService } from '../../common/services/config.service';
3
+ import { RoleScope, roleScopeRegistry, RoleScopeSource } from './core-role-scope.registry';
2
4
  import { DEFAULT_ROLE_HIERARCHY } from './core-tenant.enums';
3
5
 
4
- const SYSTEM_ROLE_PREFIX = 's_';
5
-
6
6
  /**
7
7
  * Merge handler-level and class-level @Roles() metadata arrays into a single flat array.
8
8
  * Used by RolesGuard, BetterAuthRolesGuard, and CoreTenantGuard.
@@ -23,13 +23,145 @@ export function getRoleHierarchy(): Record<string, number> {
23
23
  return ConfigService.configFastButReadOnly?.multiTenancy?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY;
24
24
  }
25
25
 
26
+ // `isSystemRole` used to be defined here. It now lives in `common/enums/role.enum.ts`, next to the
27
+ // RoleEnum members it describes, so the storage guards and the runtime guards share ONE predicate
28
+ // instead of drifting apart (they already disagreed on case). Re-exported below for compatibility.
29
+ export {
30
+ GLOBAL_ONLY_ROLES,
31
+ isForbiddenMembershipRole,
32
+ isGlobalOnlyRole,
33
+ isSystemRole,
34
+ looksLikeGlobalOnlyRole,
35
+ looksLikeSystemRole,
36
+ SYSTEM_ROLE_PREFIX,
37
+ } from '../../common/enums/role.enum';
38
+
39
+ /**
40
+ * Feeds the role-scope registry from `multiTenancy` config.
41
+ *
42
+ * Reads the config lazily on every call rather than caching, so a `ConfigService.setConfig()` in a
43
+ * test (or a future runtime reload) is picked up without re-registering the source.
44
+ */
45
+ export const configRoleScopeSource: RoleScopeSource = {
46
+ globalRoles: () => ConfigService.configFastButReadOnly?.multiTenancy?.globalOnlyRoles ?? [],
47
+ tenantRoles: () => {
48
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
49
+ return [
50
+ ...Object.keys(config?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY),
51
+ ...(config?.additionalMembershipRoles ?? []),
52
+ ];
53
+ },
54
+ };
55
+
56
+ /**
57
+ * Validate the configured role vocabulary. Throws when it cannot be enforced coherently.
58
+ *
59
+ * Called at boot (`CoreTenantModule`). Failing the boot is the right severity: every condition here
60
+ * describes a configuration whose access decisions would be ambiguous or silently wrong, and an
61
+ * ambiguous authorization rule is worse than a server that refuses to start.
62
+ */
63
+ export function assertRoleVocabularyIsCoherent(): void {
64
+ const config = ConfigService.configFastButReadOnly?.multiTenancy;
65
+ if (!config || config.enabled === false) {
66
+ return;
67
+ }
68
+
69
+ const hierarchy = Object.keys(config.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY);
70
+ const additional = config.additionalMembershipRoles ?? [];
71
+ const declaredGlobal = config.globalOnlyRoles ?? [];
72
+
73
+ // 1. A tenant role must never be named after a framework role.
74
+ const reserved = [...hierarchy, ...additional].filter((role) => isForbiddenMembershipRole(role));
75
+ if (reserved.length) {
76
+ throw new Error(
77
+ `multiTenancy: tenant role(s) [${reserved.join(', ')}] use a reserved framework role name. ` +
78
+ 'A tenant role of that name would be compared against the framework role by exact string match, ' +
79
+ 'letting a tenant owner grant themselves platform authority. Rename them (e.g. "tenantAdmin") ' +
80
+ 'and declare platform-wide roles via multiTenancy.globalOnlyRoles.',
81
+ );
82
+ }
83
+
84
+ // 2. A role cannot be global and tenant-scoped at once — it would need two sources of truth.
85
+ const both = declaredGlobal.filter((role) => hierarchy.includes(role) || additional.includes(role));
86
+ if (both.length) {
87
+ throw new Error(
88
+ `multiTenancy: role(s) [${both.join(', ')}] are declared BOTH in globalOnlyRoles and as tenant roles. ` +
89
+ 'Each role must resolve against exactly one source (user.roles for global, membership.role for tenant).',
90
+ );
91
+ }
92
+
93
+ // 3. A declared global role must not be a system role either.
94
+ const systemGlobals = declaredGlobal.filter((role) => looksLikeSystemRole(role));
95
+ if (systemGlobals.length) {
96
+ throw new Error(
97
+ `multiTenancy.globalOnlyRoles contains system role(s) [${systemGlobals.join(', ')}]. ` +
98
+ 'System roles are runtime-context checks and are never stored or granted.',
99
+ );
100
+ }
101
+ }
102
+
26
103
  /**
27
- * Check if a role is a system role (S_USER, S_EVERYONE, etc.).
28
- * System roles are checked by RolesGuard/BetterAuthRolesGuard for authentication
29
- * and by CoreTenantGuard as OR alternatives before real role checks.
104
+ * Split required roles into the ones only the PLATFORM can satisfy and the ones a TENANT can.
105
+ *
106
+ * This split is the tenant boundary. Membership roles are customer-assigned free text, and in
107
+ * tenant context they are compared against required roles by exact string match — so without the
108
+ * split, a member whose tenant role is literally `'admin'` satisfies `@Roles(RoleEnum.ADMIN)`, the
109
+ * global platform role. Anyone allowed to manage members (a tenant owner, i.e. a customer) could
110
+ * mint platform-wide access for themselves.
111
+ *
112
+ * Callers must resolve each half against its own source:
113
+ * - `global` → `user.roles` (never the membership role)
114
+ * - `tenant` → `membership.role` in tenant context, `user.roles` otherwise
115
+ *
116
+ * OR semantics across the two halves: satisfying either is enough, which keeps
117
+ * `@Roles(ADMIN, 'owner')` working as the alternative it reads as.
30
118
  */
31
- export function isSystemRole(role: string): boolean {
32
- return role.startsWith(SYSTEM_ROLE_PREFIX);
119
+ export function resolveGlobalAndTenantRoles(requiredRoles: string[]): { global: string[]; tenant: string[] } {
120
+ const global: string[] = [];
121
+ const tenant: string[] = [];
122
+ for (const role of requiredRoles ?? []) {
123
+ // The registry knows framework roles, project-declared global roles and configured tenant
124
+ // roles. `isGlobalOnlyRole` is kept as the floor so RoleEnum.ADMIN is global even before any
125
+ // source is registered (e.g. a unit test constructing the helper in isolation).
126
+ const scope = roleScopeRegistry.scopeOf(role);
127
+ (scope === RoleScope.GLOBAL || isGlobalOnlyRole(role) ? global : tenant).push(role);
128
+ }
129
+ return { global, tenant };
130
+ }
131
+
132
+ /**
133
+ * Which required roles may a TENANT MEMBERSHIP satisfy?
134
+ *
135
+ * Two tiers, and the distinction is the whole design:
136
+ *
137
+ * 1. **Unconditional** — roles with GLOBAL scope are never satisfiable by a membership. This is the
138
+ * security fix: a customer-assigned role must not answer for the platform. Always on.
139
+ * 2. **Opt-in** (`multiTenancy.strictMembershipRoles`) — narrow further to roles the project
140
+ * DECLARED as tenant-scoped, so an undeclared name cannot line up with a membership by
141
+ * coincidence.
142
+ *
143
+ * Tier 2 is not the default on purpose. A project may legitimately use exact-match roles that
144
+ * appear only in `@Roles()` and in its membership data and never in `roleHierarchy`; denying those
145
+ * at the guard by default would be a silent, fleet-wide lockout — a worse failure than the case it
146
+ * guards against, which tier 1 already covers as soon as the role is declared global.
147
+ */
148
+ export function tenantSatisfiableRoles(requiredRoles: string[]): string[] {
149
+ // Always excluded: roles whose authority is global. That is the security fix, and it is
150
+ // unconditional — a customer-assigned membership role must never answer for the platform.
151
+ const candidates = resolveGlobalAndTenantRoles(requiredRoles).tenant;
152
+
153
+ // Beyond that, opt-in. `strictMembershipRoles` narrows matching to roles the project actually
154
+ // DECLARED, so an undeclared name cannot line up with a membership by coincidence.
155
+ //
156
+ // It is deliberately NOT the default: a project may legitimately use exact-match roles that
157
+ // appear only in @Roles() and in its membership data, never in roleHierarchy. Denying those by
158
+ // default would break such setups at the guard — a silent, fleet-wide lockout — for a case the
159
+ // global/tenant split already covers as soon as the role is declared global.
160
+ if (!ConfigService.configFastButReadOnly?.multiTenancy?.strictMembershipRoles) {
161
+ return candidates;
162
+ }
163
+
164
+ return candidates.filter((role) => roleScopeRegistry.isTenantRole(role));
33
165
  }
34
166
 
35
167
  /**
@@ -3,8 +3,10 @@ import { APP_GUARD } from '@nestjs/core';
3
3
  import { MongooseModule, SchemaFactory, getModelToken } from '@nestjs/mongoose';
4
4
  import { Model } from 'mongoose';
5
5
 
6
+ import { roleScopeRegistry } from './core-role-scope.registry';
6
7
  import { CoreTenantMemberModel } from './core-tenant-member.model';
7
8
  import { TENANT_MEMBER_MODEL_TOKEN } from './core-tenant.enums';
9
+ import { assertRoleVocabularyIsCoherent, configRoleScopeSource } from './core-tenant.helpers';
8
10
  import { CoreTenantGuard } from './core-tenant.guard';
9
11
  import { CoreTenantService } from './core-tenant.service';
10
12
 
@@ -58,6 +60,16 @@ export interface CoreTenantModuleOptions {
58
60
  @Module({})
59
61
  export class CoreTenantModule {
60
62
  static forRoot(options: CoreTenantModuleOptions = {}): DynamicModule {
63
+ // Teach the role-scope registry which roles are global and which are tenant-scoped, then
64
+ // refuse to boot on a vocabulary that cannot be enforced coherently (a tenant role named after
65
+ // a framework role, or one role declared in both scopes). An ambiguous authorization rule is
66
+ // worse than a server that does not start, so this throws rather than warns.
67
+ //
68
+ // Registration is idempotent: forRoot() may run more than once across test module fixtures.
69
+ roleScopeRegistry.clear();
70
+ roleScopeRegistry.register(configRoleScopeSource);
71
+ assertRoleVocabularyIsCoherent();
72
+
61
73
  const MemberModel = options.memberModel || CoreTenantMemberModel;
62
74
  const Guard = options.guard || CoreTenantGuard;
63
75
  const Service = options.service || CoreTenantService;
@@ -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