@lenne.tech/nest-server 11.34.1 → 11.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude/rules/architecture.md +3 -1
  2. package/.claude/rules/configurable-features.md +3 -3
  3. package/.claude/rules/role-system.md +281 -0
  4. package/CLAUDE.md +11 -1
  5. package/FRAMEWORK-API.md +7 -3
  6. package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
  7. package/dist/core/common/decorators/nested-type.registry.js +17 -0
  8. package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
  9. package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
  10. package/dist/core/common/decorators/restricted.decorator.js +52 -16
  11. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  12. package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
  13. package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
  14. package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
  15. package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
  16. package/dist/core/common/decorators/unified-field.decorator.js +6 -3
  17. package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
  18. package/dist/core/common/enums/role.enum.d.ts +8 -0
  19. package/dist/core/common/enums/role.enum.js +28 -1
  20. package/dist/core/common/enums/role.enum.js.map +1 -1
  21. package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
  22. package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
  23. package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
  24. package/dist/core/common/helpers/file.helper.js +3 -2
  25. package/dist/core/common/helpers/file.helper.js.map +1 -1
  26. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
  27. package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
  28. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
  29. package/dist/core/common/helpers/gridfs.helper.js +1 -1
  30. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  31. package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
  32. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  33. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  34. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  35. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  36. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  37. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  38. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  39. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  40. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  41. package/dist/core/common/services/core-s3.service.js +4 -1
  42. package/dist/core/common/services/core-s3.service.js.map +1 -1
  43. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  44. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  45. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  46. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  47. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  48. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  49. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  50. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  51. package/dist/core/modules/file/core-file-access-audit.initializer.d.ts +13 -0
  52. package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
  53. package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
  54. package/dist/core/modules/file/core-file.service.d.ts +3 -1
  55. package/dist/core/modules/file/core-file.service.js +62 -7
  56. package/dist/core/modules/file/core-file.service.js.map +1 -1
  57. package/dist/core/modules/file/file-access.helper.d.ts +18 -0
  58. package/dist/core/modules/file/file-access.helper.js +46 -0
  59. package/dist/core/modules/file/file-access.helper.js.map +1 -0
  60. package/dist/core/modules/file/file-metadata.helper.js +1 -1
  61. package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
  62. package/dist/core/modules/file/file-roles.config.d.ts +31 -0
  63. package/dist/core/modules/file/file-roles.config.js +127 -0
  64. package/dist/core/modules/file/file-roles.config.js.map +1 -0
  65. package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
  66. package/dist/core/modules/file/file-roles.helper.js +16 -34
  67. package/dist/core/modules/file/file-roles.helper.js.map +1 -1
  68. package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
  69. package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
  70. package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
  71. package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
  72. package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
  73. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  74. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
  75. package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
  76. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  77. package/dist/core/modules/tenant/core-tenant.module.js +5 -0
  78. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  79. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  80. package/dist/core/modules/tenant/core-tenant.service.js +25 -0
  81. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  82. package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
  83. package/dist/core/modules/tus/core-tus.service.js +34 -4
  84. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  85. package/dist/core/modules/tus/index.d.ts +1 -0
  86. package/dist/core/modules/tus/index.js +1 -0
  87. package/dist/core/modules/tus/index.js.map +1 -1
  88. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  89. package/dist/core/modules/tus/tus.constants.js +2 -1
  90. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  91. package/dist/core/modules/user/core-user.service.js +5 -0
  92. package/dist/core/modules/user/core-user.service.js.map +1 -1
  93. package/dist/core/modules/user/inputs/core-user.input.js +8 -0
  94. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  95. package/dist/core.module.js +13 -1
  96. package/dist/core.module.js.map +1 -1
  97. package/dist/index.d.ts +7 -0
  98. package/dist/index.js +7 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/server/modules/file/file.service.js +4 -1
  101. package/dist/server/modules/file/file.service.js.map +1 -1
  102. package/dist/test/test.helper.d.ts +1 -0
  103. package/dist/test/test.helper.js +26 -6
  104. package/dist/test/test.helper.js.map +1 -1
  105. package/dist/tsconfig.build.tsbuildinfo +1 -1
  106. package/docs/REQUEST-LIFECYCLE.md +60 -1
  107. package/migration-guides/11.34.x-to-11.35.x.md +846 -0
  108. package/migration-guides/11.35.0-to-11.35.1.md +152 -0
  109. package/package.json +1 -1
  110. package/src/core/common/decorators/nested-type.registry.ts +42 -0
  111. package/src/core/common/decorators/restricted.decorator.ts +149 -21
  112. package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
  113. package/src/core/common/decorators/unified-field.decorator.ts +4 -3
  114. package/src/core/common/enums/role.enum.ts +116 -0
  115. package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
  116. package/src/core/common/helpers/file.helper.ts +11 -5
  117. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  118. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  119. package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
  120. package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
  121. package/src/core/common/interfaces/server-options.interface.ts +139 -7
  122. package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
  123. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  124. package/src/core/common/services/core-s3.service.ts +9 -1
  125. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  126. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  127. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  128. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  129. package/src/core/modules/file/README.md +71 -0
  130. package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -0
  131. package/src/core/modules/file/core-file.service.ts +166 -9
  132. package/src/core/modules/file/file-access.helper.ts +149 -0
  133. package/src/core/modules/file/file-metadata.helper.ts +13 -5
  134. package/src/core/modules/file/file-roles.config.ts +357 -0
  135. package/src/core/modules/file/file-roles.helper.ts +24 -60
  136. package/src/core/modules/tenant/README.md +20 -1
  137. package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
  138. package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
  139. package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
  140. package/src/core/modules/tenant/core-tenant.module.ts +12 -0
  141. package/src/core/modules/tenant/core-tenant.service.ts +56 -0
  142. package/src/core/modules/tus/README.md +46 -7
  143. package/src/core/modules/tus/core-tus.service.ts +94 -5
  144. package/src/core/modules/tus/index.ts +1 -0
  145. package/src/core/modules/tus/tus.constants.ts +17 -1
  146. package/src/core/modules/user/core-user.service.ts +12 -0
  147. package/src/core/modules/user/inputs/core-user.input.ts +28 -2
  148. package/src/core.module.ts +72 -3
  149. package/src/index.ts +7 -0
  150. package/src/server/modules/file/file.service.ts +45 -2
  151. package/src/test/test.helper.ts +54 -11
@@ -0,0 +1,846 @@
1
+ # Migration Guide: 11.34.x → 11.35.x
2
+
3
+ ## Why this is a MINOR
4
+
5
+ Per `.claude/rules/versioning.md` the MAJOR mirrors NestJS, so **MINOR is where breaking changes
6
+ live**. This release makes a previously-accepted (and always-wrong) input fail loudly: `roles`
7
+ values with the `s_*` system-role prefix are now rejected. A request that used to succeed can now
8
+ fail, so it is `11.35.0` rather than a `11.34.2` patch.
9
+
10
+ > **Coming from 11.34.0?** You are skipping `11.34.1`, which changed client-visible validation
11
+ > error messages (a custom `message` now reaches the client, and default messages no longer carry
12
+ > raw `$constraint1` placeholders). If you assert on validation error text anywhere, read
13
+ > [`11.34.0-to-11.34.1.md`](./11.34.0-to-11.34.1.md) as well — it interacts directly with the new
14
+ > message introduced here.
15
+
16
+ ## Overview
17
+
18
+ | Category | Details |
19
+ |----------|---------|
20
+ | **Security** | System roles (`s_*`) can no longer be written into `user.roles` — enforced on three layers, not just documented (§1) |
21
+ | **Breaking Changes** | Any write carrying an `s_`-prefixed role now fails: the user endpoints answer 400, `setRoles()` throws, and a direct Mongoose write throws. **This includes project roles that merely start with `s_`** (e.g. `s_manager`) — see §2 (§1, §2) |
22
+ | **Security** | GraphQL SUBSCRIPTIONS ran with no request context, so a tenant-scoped read on that path returned every tenant's rows (§7) |
23
+ | **Security** | Nested `@Restricted` silently did nothing on embedded subdocuments (§8) |
24
+ | **Security** | A reused filename made file storage authorize one document and serve another (§9) |
25
+ | **Security** | TUS uploads had no owner, and any authenticated caller could append to or delete somebody else's upload (§10) |
26
+ | **Security** | `@Roles()` on a `@Subscription` was enforced by nobody when multi-tenancy was active (§12) |
27
+ | **Security** | The `@Restricted` opt-out flag could be forged by stored data (§13) |
28
+ | **Security** | The reference file rule waved LISTINGS through, so a project exposing `findFileInfo()` leaked a full inventory (§15) |
29
+ | **New Feature** | `file.access` — the per-file rule as a declaration, one setting per project class; default keeps today's behaviour (§16) |
30
+ | **New Exports** | `isSystemRole`, `looksLikeSystemRole`, `SYSTEM_ROLE_PREFIX`, `SYSTEM_ROLE_REJECT_PATTERN`, `mongooseSystemRolePlugin` (§4); the WS context wrappers, the tenant-context registry, `resolveNestedType`, `TUS_OWNER_METADATA_KEY` (New Exports) |
31
+ | **Migration Effort** | **None**, unless you use an `s_`-prefixed role name or already have `s_*` values stored (§3), OR your subscriptions read tenant-scoped data (§7), OR your models carry nested `@Restricted` fields (§8) |
32
+
33
+ ## Quick Migration
34
+
35
+ ```bash
36
+ # npm mode
37
+ pnpm update @lenne.tech/nest-server@11.35.0
38
+ pnpm run build && pnpm test
39
+
40
+ # vendor mode
41
+ /lt-dev:backend:update-nest-server-core
42
+ ```
43
+
44
+ Then run the one-time audit in §3.
45
+
46
+ ### Vendor mode: which files, and which ones belong together
47
+
48
+ `/lt-dev:backend:update-nest-server-core` adopts these for you, but the GROUPINGS matter — every one of
49
+ them is half-wired if you take one side and not the other, and a half-wired enforcement layer is worse
50
+ than none because it reads as protection.
51
+
52
+ **Nine new files** (eight rows — the two file-module files ship as one pair):
53
+
54
+ | File | Belongs with |
55
+ |------|--------------|
56
+ | `common/plugins/mongoose-system-role.plugin.ts` | `common/enums/role.enum.ts` — the plugin is the unconditional layer, the enum holds the predicates (§1) |
57
+ | `modules/tenant/core-role-scope.registry.ts` | `modules/tenant/core-tenant.helpers.ts` + `modules/tenant/core-tenant.module.ts` + `common/enums/role.enum.ts` — the registry holds the global-vs-tenant split the guards resolve against, `core-tenant.helpers.ts` feeds it from configuration, and `core-tenant.module.ts` is where `forRoot()` registers that source and runs `assertRoleVocabularyIsCoherent()`. Without the module wiring the registry stays empty, every role resolves to `RoleScope.UNKNOWN`, and the boot check that refuses an incoherent vocabulary never runs (§5) |
58
+ | `common/decorators/nested-type.registry.ts` | `decorators/restricted.decorator.ts` + `decorators/unified-field.decorator.ts` — the registry is written by one and read by the other (§8) |
59
+ | `common/decorators/restrictions-checked.marker.ts` | `decorators/restricted.decorator.ts` + both interceptors — all four read the marker; an old truthy check anywhere reopens the bypass (§13) |
60
+ | `common/helpers/graphql-ws-context.helper.ts` | `common/services/core-tenant-context.registry.ts` + `core.module.ts` + `modules/tenant/core-tenant.guard.ts` — the context is useless without the resolver, and unreached without the `core.module.ts` wiring (§7) |
61
+ | `common/services/core-tenant-context.registry.ts` | as above |
62
+ | `common/helpers/execution-context-request.helper.ts` | all three guards: `modules/auth/guards/roles.guard.ts`, `modules/better-auth/better-auth-roles.guard.ts`, `modules/tenant/core-tenant.guard.ts`. Adopting it for one guard leaves the others blind on the WebSocket (§12) |
63
+ | `modules/file/file-access.helper.ts` + `modules/file/file-roles.config.ts` | `modules/file/core-file.service.ts`. `file-roles.config.ts` is a SPLIT of `file-roles.helper.ts` — take both, or the service closes an import cycle that dies under SWC (§16) |
64
+
65
+ **Changed elsewhere:** `core.module.ts`, `index.ts`,
66
+ `common/interfaces/server-options.interface.ts`, `common/helpers/gridfs.helper.ts`,
67
+ `common/plugins/mongoose-tenant.plugin.ts`, `common/services/core-s3.service.ts`,
68
+ `modules/file/{core-file.service,file-metadata.helper,file-roles.helper}.ts`,
69
+ `modules/tus/{core-tus.service,tus.constants,index}.ts`, `modules/user/inputs/core-user.input.ts`,
70
+ `modules/user/core-user.service.ts`,
71
+ `modules/tenant/{core-tenant.helpers,core-tenant.module,core-tenant.service}.ts`, and the three
72
+ module READMEs (`file`, `tenant`, `tus`) plus `modules/file/INTEGRATION-CHECKLIST.md`.
73
+
74
+ `core-tenant.module.ts` and `core-tenant.service.ts` are easy to miss and both carry enforcement:
75
+ `forRoot()` registers the role-scope source and asserts the vocabulary is coherent, and
76
+ `core-tenant.service.ts` gains `assertAssignableMembershipRole()`, which refuses a membership role
77
+ named after a system (`s_*`) or global-only role at assignment time (§5).
78
+
79
+ The remaining changed files — `common/enums/role.enum.ts`,
80
+ `common/decorators/{restricted,unified-field}.decorator.ts`, both interceptors
81
+ (`check-security`, `response-model`), and all three guards
82
+ (`modules/auth/guards/roles.guard.ts`, `modules/better-auth/better-auth-roles.guard.ts`,
83
+ `modules/tenant/core-tenant.guard.ts`) — are the counterparts already named in the "Belongs with"
84
+ column above. Take them with the new file they pair with.
85
+
86
+ **Also worth taking:** `src/test/test.helper.ts` (subscription `connectionParams`, and a refused
87
+ subscription now rejects instead of delivering an indistinguishable `null` — without it you cannot test
88
+ a role-gated subscription at all) and `src/server/modules/file/file.service.ts` as the reference rule.
89
+
90
+ ## 1. Security: `s_*` values can no longer be stored in `user.roles`
91
+
92
+ ### What happened
93
+
94
+ `roles` in `CoreUserInput` was an unvalidated `string[]`. The `@Restricted(INPUT, ADMIN)` on the
95
+ field limits **who** may send it, but nothing limited **what** it may hold. `hasRole()` is a plain
96
+ string intersection, so a stored `'s_self'` satisfied every `S_SELF` check — for the core user
97
+ module that includes `updateUser` / `deleteUser` on **arbitrary** users (mail/password change →
98
+ account takeover), without the account carrying `admin`. The rule "NEVER store S_ roles in
99
+ user.roles" existed only as documentation.
100
+
101
+ The actor must already be an admin (no escalation from below), but a typo or copy-paste in an
102
+ admin tool was enough to mint a dormant skeleton key — one that passes an eyeball review, because
103
+ the account shows no privileged role.
104
+
105
+ ### What changed — three layers, deliberately redundant
106
+
107
+ | Layer | Covers | On violation | Configurable |
108
+ |-------|--------|--------------|--------------|
109
+ | `CoreUserInput.roles` validator | the input/DTO path (REST + GraphQL, create + update) | 400 with a per-field message | no, but **a subclass override replaces it** — see §2 |
110
+ | `CoreUserService.setRoles()` | the framework's canonical role-assignment API, which writes straight through `findByIdAndUpdate` | `BadRequestException` before the DB round-trip | no |
111
+ | `mongooseSystemRolePlugin` | **every** Mongoose write, including `force: true`, `runWithBypassRoleGuard()` and direct `Model` calls | throws | **no** — registered unconditionally |
112
+
113
+ The third layer is why the claim "enforced" is now accurate rather than aspirational. It is
114
+ separate from `mongooseRoleGuardPlugin` on purpose:
115
+
116
+ - `mongooseRoleGuardPlugin` answers *who may change roles*. It is configurable
117
+ (`security.mongooseRoleGuardPlugin`), it can be bypassed, and it **strips** the change.
118
+ - `mongooseSystemRolePlugin` answers *which values may exist at all*. It cannot be configured or
119
+ bypassed, and it **throws** — silently dropping the value would hide exactly the admin-tool
120
+ misconfiguration that creates the problem.
121
+
122
+ Being an ADMIN, or holding a bypass, grants authority over the *change*. It never grants permission
123
+ to write a value that is invalid by construction.
124
+
125
+ ### The guard refuses what a write INTRODUCES, not what is already stored
126
+
127
+ This distinction is load-bearing, not leniency. `CrudService.update()` writes the whole object back,
128
+ so an ordinary login (`updateRefreshToken` → `update`) re-sends the stored `roles` verbatim. If the
129
+ plugin refused that, upgrading would **lock every already-contaminated account out of the system** —
130
+ and would do the same, fleet-wide, to any project whose own role name starts with `s_`.
131
+
132
+ | Write | Result |
133
+ |-------|--------|
134
+ | Adding `s_self` to a document that does not have it | refused |
135
+ | Writing back `['admin', 's_manager']` on a document that already stores exactly that | allowed |
136
+ | Adding `s_self` on top of an already-stored `s_manager` | refused (the message names only `s_self`) |
137
+ | Removing an `s_*` value (`$pull`, or a narrower array) | allowed — this is the cleanup path |
138
+
139
+ A stale value therefore keeps working until you clean it up. It does **not** keep working as a
140
+ harmless quirk: the account remains a skeleton key for as long as the value is stored, which is why
141
+ the audit in §3 is not optional. `updateMany` is the one exception — it can span documents with
142
+ different stored values, so there is no single baseline and any system role in its payload is
143
+ refused outright.
144
+
145
+ ## 2. Two ways to lose the protection (read this if you subclass or use `s_` role names)
146
+
147
+ ### 2a. A project role starting with `s_` is now rejected
148
+
149
+ The check is a **prefix rule**, not an allowlist of the six `RoleEnum.S_*` members. Any value that
150
+ starts with `s_` after trimming — case-insensitively — is refused:
151
+
152
+ | Value | Result |
153
+ |-------|--------|
154
+ | `s_self`, `S_SELF`, `' s_self'` | rejected |
155
+ | `s_manager`, `s_support` (your own roles) | **rejected** |
156
+ | `gs_editor`, `admin`, `editor` | accepted |
157
+
158
+ This is deliberate: the framework cannot distinguish a project role from a future system role, and
159
+ a false rejection is fixable by renaming while a false acceptance is a silent authorization hole.
160
+
161
+ **If you use an `s_`-prefixed role name, rename it before upgrading.** Update the stored values,
162
+ your `@Roles()` / `@Restricted()` usages and any seed data together — the framework does not
163
+ migrate stored data, it only refuses to write new violations.
164
+
165
+ ### 2b. Redeclaring `roles` in a subclass REPLACES the validator
166
+
167
+ `MapAndValidatePipe` walks the prototype chain child-first and skips a property once a child class
168
+ has validated it. So a project input that redeclares `roles` — for **any** reason, even just to add
169
+ a description — silently loses the inherited guard on the input path:
170
+
171
+ ```typescript
172
+ // LOSES the s_* guard on the input path
173
+ @InputType()
174
+ export class UserInput extends CoreUserInput {
175
+ @UnifiedField({ description: 'Roles', isArray: true, isOptional: true, type: String })
176
+ override roles?: string[] = undefined;
177
+ }
178
+
179
+ // CORRECT: re-declare the guard alongside whatever you are adding
180
+ import { SYSTEM_ROLE_PREFIX, SYSTEM_ROLE_REJECT_PATTERN } from '@lenne.tech/nest-server';
181
+
182
+ @InputType()
183
+ export class UserInput extends CoreUserInput {
184
+ @UnifiedField({
185
+ description: 'Roles',
186
+ isArray: true,
187
+ isOptional: true,
188
+ type: String,
189
+ validator: opts => [
190
+ IsString(opts),
191
+ IsIn(['admin', 'editor'], opts), // your own allowlist, if you want one
192
+ Matches(SYSTEM_ROLE_REJECT_PATTERN, {
193
+ ...opts,
194
+ message: `System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles`,
195
+ }),
196
+ ],
197
+ })
198
+ override roles?: string[] = undefined;
199
+ }
200
+ ```
201
+
202
+ An `IsIn` allowlist happens to exclude `s_*` values incidentally, so that particular override stays
203
+ safe — but do not rely on the accident. **`mongooseSystemRolePlugin` backstops all of this**, so an
204
+ override degrades the error message rather than reopening the hole. You still want the DTO-level
205
+ guard for the better message and to fail before the DB round-trip.
206
+
207
+ ## 3. What you should do
208
+
209
+ 1. **Audit once** for values already stored:
210
+ ```js
211
+ db.users.find({ roles: /^\s*s_/i })
212
+ ```
213
+ Any hit is either a genuine misconfiguration (remove it — depending on your endpoints it was
214
+ exploitable) or one of your own `s_`-prefixed role names (rename it, see §2a). The upgrade does
215
+ not touch stored data.
216
+
217
+ 2. **Check your user inputs** for a `roles` override (§2b).
218
+
219
+ 3. **Optional hardening:** narrow the accepted values to your project's own assignable set with
220
+ `IsIn([...], opts)` — using the combined form in §2b, so the framework guard survives.
221
+
222
+ ## Compatibility Notes
223
+
224
+ | Pattern | Status |
225
+ |---------|--------|
226
+ | Custom project roles without an `s_` prefix | Unaffected |
227
+ | `roles: []` / omitting `roles` | Unaffected |
228
+ | Subclassing `CoreUserInput` **without** redeclaring `roles` | Unaffected — the validator is inherited |
229
+ | Subclassing **with** a `roles` override | Input-path validator lost; plugin still enforces (§2b) |
230
+ | `CoreUserCreateInput` / sign-up flows | Covered — the field is inherited |
231
+ | `setRoles()` | Now throws on `s_*` (§1) |
232
+ | `force: true`, `runWithBypassRoleGuard()`, direct `Model` writes | Now throw via the plugin — previously unchecked |
233
+ | `$pull` of an `s_*` value | **Allowed** — removing a bad value is the cleanup this release asks for |
234
+ | BetterAuth `additionalUserFields` | Unaffected — `roles` was already locked to `input: false` |
235
+ | `isSystemRole` imported from `.../tenant/core-tenant.helpers` | Still works (re-exported); the definition moved to `common/enums/role.enum` |
236
+ | Tenant membership roles | **Covered — see §5.** A membership named `admin` no longer satisfies `@Roles(RoleEnum.ADMIN)`, and one named `s_self` no longer satisfies `@Restricted(S_SELF)` |
237
+
238
+ ## Troubleshooting
239
+
240
+ **`Validation failed for 1 field: roles (matches)` (HTTP 400)**
241
+ The input path rejected an `s_*` value. The top-level message is built from constraint *names*; the
242
+ readable text is in the response body beside it:
243
+ `{ "roles": { "matches": "System roles (s_*) must never be stored in user.roles" } }`.
244
+ Over GraphQL that body is not forwarded — check the REST response, or the server log, to see which
245
+ value was refused.
246
+
247
+ **`System roles (s_*) must never be stored in user.roles: s_manager`**
248
+ Thrown by `setRoles()` or by `mongooseSystemRolePlugin`. The offending values are named in the
249
+ message. If this is one of your own role names, see §2a.
250
+
251
+ **A previously working seed / migration now fails**
252
+ Expected — the plugin covers seeds and migrations too. Rename the role, or remove the `s_*` value.
253
+ There is no bypass, by design.
254
+
255
+ **My subclass stopped rejecting `s_*` with a nice error, but writes still fail**
256
+ That is §2b: the override replaced the input validator, and the plugin caught the write instead.
257
+ Re-declare the guard as shown there.
258
+
259
+ ## 5. Tenant boundary: a membership role can no longer confer global authority
260
+
261
+ ### What happened
262
+
263
+ Roles from three trust levels shared one namespace and were compared with `===`. Membership roles
264
+ are **customer-assigned free text** (`addMember()` accepts any non-empty string), and whoever may
265
+ manage members is typically a tenant owner — a customer. Two escalations followed:
266
+
267
+ | A tenant owner sets their membership role to… | …and thereby satisfied |
268
+ |---|---|
269
+ | `admin` | `@Roles(RoleEnum.ADMIN)` — the **global platform** role, in tenant context |
270
+ | `s_self` | `@Restricted(S_SELF)` on **arbitrary** records — ownership was never compared |
271
+
272
+ Note that enums do not help here: `RoleEnum.ADMIN` *is* the string `'admin'` at runtime.
273
+
274
+ ### What changed
275
+
276
+ Each required role is now resolved against the source entitled to answer it:
277
+
278
+ | Required | Resolved against |
279
+ |----------|------------------|
280
+ | `RoleEnum.ADMIN` | `user.roles` — **never** `membership.role` |
281
+ | tenant/project roles | `membership.role` in tenant context, `user.roles` otherwise |
282
+ | system roles | their dedicated checks, never a string comparison |
283
+
284
+ OR semantics across both halves, so `@Roles(ADMIN, 'owner')` still reads as alternatives.
285
+ Additionally, `addMember()` / `updateMemberRole()` now **throw** on a system role or a global-only
286
+ role (case-insensitive, trimmed).
287
+
288
+ ### The intended model is unchanged
289
+
290
+ ```typescript
291
+ // Global admin — full access to EVERY tenant. Unchanged, still works.
292
+ user.roles = [RoleEnum.ADMIN];
293
+
294
+ // Per-tenant admin — one tenant only. Name it anything except a framework role.
295
+ await tenantService.addMember(tenantId, userId, 'tenantAdmin'); // or 'spaceAdmin'
296
+ @Roles('tenantAdmin')
297
+ ```
298
+
299
+ ### What you should do
300
+
301
+ - **Nothing**, if your tenant roles are named `owner` / `manager` / `member` / `tenantAdmin` /
302
+ `spaceAdmin` or similar.
303
+ - **If any membership role is literally `admin`** (or an `s_*` name): rename it. Existing rows keep
304
+ working as an ordinary tenant role — they are simply no longer mistaken for the global role — but
305
+ new assignments of that name are refused. Find them with:
306
+ ```js
307
+ db.tenantmembers.find({ role: { $in: [/^\s*admin\s*$/i, /^\s*s_/i] } })
308
+ ```
309
+ - **Adding your own framework-wide role?** Classify it in `GLOBAL_ONLY_ROLES`;
310
+ `tests/unit/role-classification-invariants.spec.ts` fails until you do.
311
+
312
+ ### Declare YOUR OWN global roles
313
+
314
+ The framework only knows its own vocabulary. If you guard a platform-wide endpoint with
315
+ `@Roles('auditor')`, say so — otherwise `auditor` is an ordinary role and, in tenant context, gets
316
+ answered from `membership.role`:
317
+
318
+ ```typescript
319
+ multiTenancy: {
320
+ roleHierarchy: { member: 1, tenantAdmin: 2, owner: 3 }, // per tenant
321
+ globalOnlyRoles: ['auditor', 'support'], // platform-wide, user.roles only
322
+ }
323
+ ```
324
+
325
+ The scope is stored as an **attribute**, not encoded in the role name — which is what keeps this
326
+ open for admin-managed roles in the database later: a `RoleScopeSource` reading from Mongo can be
327
+ registered next to the config source and every guard keeps working unchanged. (Encoding the scope
328
+ in the string instead, e.g. `t:owner`, would put it in a value that whoever creates the role can
329
+ mistype, and would add a normalization step inside the authorization path.)
330
+
331
+ ### ⚠️ Boot now FAILS on an incoherent role vocabulary
332
+
333
+ `CoreTenantModule.forRoot()` refuses to start on:
334
+
335
+ | Configuration | Why it fails |
336
+ |---|---|
337
+ | `roleHierarchy: { admin: 3, … }` | a tenant role named after a framework role — the escalation above |
338
+ | `globalOnlyRoles: ['x']` + `roleHierarchy: { x: 2 }` | one role, two sources of truth |
339
+ | `globalOnlyRoles: ['s_user']` | system roles are never granted or stored |
340
+
341
+ **This is the most likely upgrade blocker.** The framework's own docs previously recommended
342
+ `roleHierarchy: { viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 }` — case 1. If you copied
343
+ that example, rename `admin` to `tenantAdmin` in your config, in your `@Roles()` usages, and in
344
+ stored membership rows:
345
+
346
+ ```js
347
+ db.tenantmembers.updateMany({ role: 'admin' }, { $set: { role: 'tenantAdmin' } })
348
+ ```
349
+
350
+ An ambiguous authorization rule is worse than a server that does not start, which is why this
351
+ throws rather than warns.
352
+
353
+ ### Optional: `strictMembershipRoles` (deny by default)
354
+
355
+ ```typescript
356
+ multiTenancy: {
357
+ roleHierarchy: { member: 1, tenantAdmin: 2, owner: 3 },
358
+ additionalMembershipRoles: ['auditorOfTenant'], // exact-match roles without a level
359
+ strictMembershipRoles: true,
360
+ }
361
+ ```
362
+
363
+ Only declared roles may be assigned **and** may satisfy a required role. Not the default: a project
364
+ may legitimately use exact-match roles that appear only in `@Roles()` and in its membership data,
365
+ and denying those at the guard by default would be a silent, fleet-wide lockout. Turn it on for the
366
+ strictest posture — recommended for regulated data.
367
+
368
+ ## 6. Data isolation: aggregations that join another collection
369
+
370
+ ### What happened
371
+
372
+ `mongooseTenantPlugin` prepended a `$match` to the pipeline of the collection being aggregated.
373
+ That constrains the **source** collection only. `$lookup`, `$unionWith` and `$graphLookup` read a
374
+ **different** collection — and that collection's own `aggregate` hook never fires, because the join
375
+ runs inside the source pipeline.
376
+
377
+ So an aggregation shaped like an ordinary report returned every tenant's rows from the joined
378
+ collection:
379
+
380
+ ```js
381
+ // Ran as tenant A — and returned tenant B's rows too
382
+ ItemModel.aggregate([
383
+ { $lookup: { from: 'records', pipeline: [], as: 'records' } },
384
+ ]);
385
+ ```
386
+
387
+ `$unionWith` behaved the same way. Verified by test, not by reading.
388
+
389
+ ### What changed
390
+
391
+ The plugin now walks the pipeline and injects the tenant filter into every cross-collection stage,
392
+ recursively (these stages nest, and `$facet` holds a sub-pipeline per key):
393
+
394
+ | Stage | Where the filter goes |
395
+ |-------|----------------------|
396
+ | `$lookup` | prepended to its `pipeline` (MongoDB 5.0+ allows this alongside `localField`/`foreignField`) |
397
+ | `$unionWith` | prepended to its `pipeline`; the string shorthand is expanded to the object form |
398
+ | `$graphLookup` | merged into `restrictSearchWithMatch`, so it applies to every recursive step |
399
+
400
+ A joined collection is only constrained when it is **itself** tenant-scoped. Joining a global
401
+ lookup table is untouched — filtering it by `tenantId` would silently return nothing.
402
+
403
+ ### `estimatedDocumentCount()` now throws inside a tenant context
404
+
405
+ It reads collection metadata rather than running a query, so MongoDB ignores any filter — there is
406
+ nothing to inject, and an unchanged result hands a tenant the row count of the whole collection.
407
+ Use `countDocuments()`, which is filtered. A platform admin without a tenant header is unaffected.
408
+
409
+ ### What you should do
410
+
411
+ - **Review aggregations that join tenant-scoped collections.** They now return only your own
412
+ tenant's rows. If one silently relied on the previous behaviour, its results will shrink — which
413
+ is the correction, not a regression.
414
+ - `estimatedDocumentCount()` → `countDocuments()` on tenant-scoped models.
415
+
416
+ ## 7. Data isolation: GraphQL subscriptions now run inside a request context
417
+
418
+ ### What happened
419
+
420
+ `RequestContextMiddleware` is Express middleware. A WebSocket UPGRADE is not a `request` event, so it
421
+ traverses no middleware — and `CoreTenantGuard.getRequest()` looks for `context.req`, which a
422
+ subscription context (the graphql-ws `extra` object) does not have, so the guard returned `true`
423
+ without deciding anything.
424
+
425
+ The result was invisible from the query itself. `mongooseTenantPlugin` reads "no RequestContext" as
426
+ "system operation, no filter" — correct for a cron job or a migration, and exactly wrong here. **A
427
+ tenant-scoped read performed while delivering a subscription message returned every tenant's rows**,
428
+ and the plugin's own safety net could not fire, because there was no context in which to notice.
429
+ `mongooseRoleGuardPlugin` and the audit-fields plugin were equally blind on that path.
430
+
431
+ ### What changed
432
+
433
+ `CoreModule` now installs a context-aware `execute` / `subscribe` pair on all three of its GraphQL
434
+ driver builders. Every WebSocket operation — subscriptions AND the queries/mutations graphql-ws
435
+ carries — runs inside a `RequestContext` holding:
436
+
437
+ | Field | Source |
438
+ |-------|--------|
439
+ | `currentUser` | the user the connection authenticated as (`onConnect`) |
440
+ | `tenantId` | the handshake's tenant header, **validated against an active membership** |
441
+ | `tenantIds` | the subscriber's active memberships, when no header was sent |
442
+ | `language` | `accept-language` from the handshake, when present |
443
+
444
+ The context is established for the initial subscribe **and for every delivered message** — the
445
+ per-event execution (`resolve`, `filter`, every field resolver) runs inside the async iterator's
446
+ `next()`, long after `subscribe()` returned, so wrapping only the subscribe call would have covered
447
+ nothing that matters.
448
+
449
+ ### What you should do
450
+
451
+ - **Nothing, if your subscription resolvers do not read tenant-scoped data.**
452
+ - **If they do:** they are now filtered. A resolver that silently returned rows from every tenant
453
+ returns only the subscriber's from now on — that is the correction.
454
+ - **A tenant-scoped read with NO resolvable tenant is now REFUSED** (the safety net), where it
455
+ previously returned everything. That happens when the socket is anonymous, or when the handshake
456
+ names a tenant the subscriber is not a member of. Send the tenant via `connectionParams`:
457
+ ```typescript
458
+ createClient({ connectionParams: { Authorization: `Bearer ${token}`, 'x-tenant-id': tenantId } })
459
+ ```
460
+ - **System-internal work inside a subscription** (a cron-like side effect) must now say so
461
+ explicitly, exactly as elsewhere: `RequestContext.runWithBypassTenantGuard(() => …)`.
462
+ - `TestHelper` gained a `connectionParams` option so a tenant-scoped subscription can be tested.
463
+
464
+ **Vendor mode:** new file `src/core/common/helpers/graphql-ws-context.helper.ts` plus
465
+ `src/core/common/services/core-tenant-context.registry.ts`; changed `src/core.module.ts` and
466
+ `src/core/modules/tenant/core-tenant.guard.ts`. All four belong together.
467
+
468
+ ## 8. `@Restricted` now applies to nested embedded subdocuments
469
+
470
+ ### What happened
471
+
472
+ `checkRestricted()` read restriction metadata off the VALUE's constructor. `CoreModel.map()` is a
473
+ shallow `Object.assign`, `prepareOutput()` maps only the target model and `ResponseModelInterceptor`
474
+ only the top-level item — so an embedded subdocument read out of MongoDB is a **plain object**, whose
475
+ constructor is `Object`, which carries no metadata. Every nested `@Restricted` therefore evaluated to
476
+ "no restrictions" and the field was returned in full. The same class WAS filtered correctly when the
477
+ value happened to be an instance, which is why this went unnoticed.
478
+
479
+ ```typescript
480
+ @ObjectType()
481
+ class Insurance {
482
+ @UnifiedField({ roles: RoleEnum.ADMIN, type: () => String })
483
+ policyNumber?: string; // ← returned to EVERY caller before 11.35.0
484
+ }
485
+
486
+ @ObjectType()
487
+ class Patient extends CorePersistenceModel {
488
+ @UnifiedField({ type: () => Insurance })
489
+ insurance?: Insurance;
490
+ }
491
+ ```
492
+
493
+ ### What changed
494
+
495
+ The nested type registry that `@UnifiedField` already fills is now also read by `checkRestricted()`,
496
+ so a plain nested value is matched against the class its parent DECLARED — at every level, and for
497
+ every item of a declared array.
498
+
499
+ ### What you should do
500
+
501
+ - **Audit your models for nested `@Restricted` fields.** They start working now. A response that used
502
+ to include a nested restricted field will lose it for callers who may not see it (correct), and an
503
+ INPUT carrying one now throws 403 for such a caller where it was previously accepted.
504
+ - **Declare nested types**: only `@UnifiedField({ type: () => X })` records what a property holds. A
505
+ property with no declared type stays unchecked — deliberately, because such a value is just as
506
+ legitimately free-form JSON, a `Map` or a scalar, and failing closed there would strip far more than
507
+ it protects.
508
+ - A **class-level** `@Restricted` on a nested type strips the nested value's contents and leaves an
509
+ empty container (the documented `checkObjectItself: false` default) — it does not remove the key.
510
+
511
+ ## 9. File storage: a reused filename resolves to the MOST RECENT file
512
+
513
+ ### What happened
514
+
515
+ Filenames are unique in no store and are client-supplied on both the multer and the tus path. Under
516
+ GridFS, `bucket.find({ filename })` returned natural order (the oldest document) while
517
+ `openDownloadStreamByName()` defaults to `revision: -1` (the newest). So with two files sharing a
518
+ name, `getFileInfoByName()` / `getRawFileInfoByName()` authorized against one document and
519
+ `getFileStreamByName()` / `getBufferByName()` / `duplicateByName()` served another: **a per-file
520
+ ownership rule approved the caller's own file and handed over somebody else's bytes** — across
521
+ tenants, since the file stores carry no tenant scope. The S3 and filesystem drivers resolved an id
522
+ first and never had the split, which is exactly why one suite per driver could not see it.
523
+
524
+ ### What changed
525
+
526
+ - All three stores resolve a by-name lookup to the **most recent** file of that name
527
+ (`uploadDate` desc, `_id` as tie-break). Under GridFS that matches its own revision semantics, so
528
+ the bytes a caller receives do not move; what changed is that the document authorization inspects is
529
+ the one being served.
530
+ - Under S3 / filesystem the pick moves from "natural order" (usually the oldest) to the newest.
531
+ - Every by-name read path resolves a document and then reads **by id**.
532
+
533
+ ### What you should do
534
+
535
+ - **Prefer the id routes** (`GET /files/id/:id`, `getFileInfo`, `getFileStream`). The by-name route is
536
+ inherently ambiguous and is documented as such.
537
+ - **If you duplicate files:** `duplicateById()` keeps the source's filename and the copy carries no
538
+ `metadata` by design. From now on that copy WINS the name, so an ownership rule keyed on
539
+ `metadata.ownerId` refuses it. Pass `serviceOptions.metadata` to give the copy its own owner, or
540
+ give it its own name.
541
+
542
+ ## 10. TUS: uploads now have an owner, and only the owner may touch them
543
+
544
+ ### What happened
545
+
546
+ `tus.roles` (default `S_USER` since 11.33.0) decides who may reach the tus endpoint. It says nothing
547
+ about WHICH upload a caller may touch, and the protocol is built around a per-upload URL: `HEAD`
548
+ (offset), `PATCH` (append bytes) and `DELETE` (terminate) all addressed an upload by id with only that
549
+ coarse gate. Two consequences:
550
+
551
+ 1. **Nothing recorded the uploader.** The finished file's metadata carried no owner, so the per-file
552
+ ownership rule `CoreFileService.checkRights()` documents could never authorize a tus-uploaded
553
+ file — it failed closed for everyone but ADMIN. A project following the documented pattern ended up
554
+ with files nobody could download.
555
+ 2. **Nothing checked the toucher.** Any other authenticated caller who learned an id could append
556
+ bytes to somebody else's upload — bytes then migrated into the file store under the ORIGINAL
557
+ uploader's filename — or destroy it.
558
+
559
+ ### What changed
560
+
561
+ - `onUploadCreate` records the authenticated caller in the upload's metadata under
562
+ `TUS_OWNER_METADATA_KEY` (`ltOwnerId`), **overwriting** any client-supplied value.
563
+ - `onIncomingRequest` refuses a request naming an upload the caller does not own, with **404** (the
564
+ same "a refusal is indistinguishable from a missing resource" policy the file module uses).
565
+ - The finished file's metadata gains `ownerId`, so the documented ownership rule works.
566
+
567
+ ### What you should do
568
+
569
+ - **Nothing for a normal authenticated upload flow.**
570
+ - **An owner-LESS upload stays reachable by anyone who may reach the endpoint** — deliberately: uploads
571
+ created before this release carry no owner, and neither does an intentionally public form
572
+ (`tus.roles: [RoleEnum.S_EVERYONE]`). Denying those would break in-flight uploads on upgrade and a
573
+ documented configuration.
574
+ - **Overriding `CoreTusService`?** `readRequestUserId()` and `assertUploadOwnership()` are `protected`
575
+ — override to read the owner from elsewhere (an API key, a signed form token) or to let a support
576
+ role resume any upload.
577
+
578
+ ## 11. `excludeSchemas` now warns when it disables isolation
579
+
580
+ `multiTenancy.excludeSchemas` is an OFF SWITCH for data isolation, per model: a listed schema gets no
581
+ tenant filter at all. The plugin only ever attaches to schemas that DECLARE a `tenantId` field, so
582
+ listing such a model overrides its author's intent — and nothing said so. The framework's own docs
583
+ carried `excludeSchemas: ['User', 'Session']` in a copyable `@example` from 11.20.0 onwards, which on a
584
+ project with per-tenant users switches filtering off for the account collection.
585
+
586
+ The plugin now logs a warning once per model, naming it. **That suggestion is withdrawn from the
587
+ docs**: it is only correct for the global-user model (one account reaching several tenants, memberships
588
+ carrying the scope). If your users have a `tenantId`, remove `User` from `excludeSchemas`. Do not add a
589
+ model just because a `populate()` returns null across tenants — that is the isolation working.
590
+
591
+ ## 12. `@Roles()` on a `@Subscription` is now enforced
592
+
593
+ ### What happened
594
+
595
+ All three role guards resolved the request the same way, and all three got the WebSocket wrong the
596
+ same way:
597
+
598
+ ```typescript
599
+ ctx.getContext()?.req || context.switchToHttp().getRequest()
600
+ ```
601
+
602
+ A subscription's GraphQL context is the graphql-ws `extra` object, which has no `req`. The HTTP
603
+ fallback then runs on a GraphQL context, where `switchToHttp().getRequest()` yields the resolver's
604
+ ROOT value — `undefined` at subscribe time. So the guards were handed nothing, and what they did with
605
+ nothing differed in the worst possible way:
606
+
607
+ | Configuration | Behaviour before 11.35.0 |
608
+ |---------------|--------------------------|
609
+ | `multiTenancy` active | `CoreTenantGuard` hit `if (!request) return true` and GRANTED access. The role guard delegates non-system roles to it, so `@Roles()` on a subscription was checked by **nobody**: a plain `member` reached a `tenantAdmin`-gated subscription and received its messages |
610
+ | no `multiTenancy` | the role guard found no token and refused EVERYONE, so the decorator silently meant "locked" instead of "open" |
611
+
612
+ ### What changed
613
+
614
+ One shared resolver (`resolveGuardRequest`, `src/core/common/helpers/execution-context-request.helper.ts`)
615
+ used by `RolesGuard`, `BetterAuthRolesGuard` and `CoreTenantGuard`. It recognises the subscription
616
+ context and returns it as the request-like object — everything a guard needs is on it, because
617
+ `CoreModule`'s `onConnect` records the authenticated `user` and the client's `connectionParams`.
618
+
619
+ It deliberately does NOT accept any object with a `user` field: a subscription PAYLOAD very plausibly
620
+ has one (`pubSub.publish('userCreated', user)`), and treating that as the caller would turn the
621
+ published user into the apparent requester. The graphql-ws marker (`socket` / `request` /
622
+ `connectionParams`) is required.
623
+
624
+ ### What you should do
625
+
626
+ - **A `@Roles()`-gated subscription now behaves like the query of the same name.** If a subscriber
627
+ used to receive messages they were not entitled to, they stop.
628
+ - **A subscription that was silently locked in a non-tenant project starts working.** Check that its
629
+ `@Roles()` says what you meant.
630
+ - **Send the tenant with the handshake** when a subscription is tenant-scoped:
631
+ `createClient({ connectionParams: { Authorization: …, 'x-tenant-id': … } })`. A handshake naming a
632
+ tenant the subscriber is not a member of is now refused at the guard (403) instead of being scoped
633
+ to nothing.
634
+ - `TestHelper` subscriptions now REJECT on an error-bearing message instead of pushing an
635
+ indistinguishable `null` — attach the handler when you start the subscription, not after a wait, or
636
+ Node reports the rejection as unhandled.
637
+
638
+ ## 13. The `@Restricted` opt-out can no longer be forged by data
639
+
640
+ `_objectAlreadyCheckedForRestrictions` short-circuits three layers: `checkRestricted()` returns the
641
+ object untouched, `CheckSecurityInterceptor` skips `securityCheck()`, and `ResponseModelInterceptor`
642
+ skips the Plain→Model conversion. The opt-out is legitimate — without it one response is checked three
643
+ times — but it was recognised by a **truthy test on a well-known property name**, and nothing in the
644
+ framework ever wrote it. So a document carrying `_objectAlreadyCheckedForRestrictions: true` (a raw
645
+ write, a `strict: false` schema, a restored export) disabled EVERY field-level access check for that
646
+ object, silently, on the output path where a skipped check produces no error at all.
647
+
648
+ The property name is unchanged; only the way it is recognised is. The value must now be a
649
+ module-private symbol, set through `markRestrictionsChecked(obj)` — non-enumerable, so it never
650
+ reaches a response body and cannot re-enter through a round trip.
651
+
652
+ **If you set the flag by hand**, switch to the helper:
653
+
654
+ ```diff
655
+ + import { markRestrictionsChecked } from '@lenne.tech/nest-server';
656
+
657
+ const result = await super.create(createInput, { currentUser, force: true });
658
+ if (result) {
659
+ - (result as any)._objectAlreadyCheckedForRestrictions = true;
660
+ + markRestrictionsChecked(result);
661
+ }
662
+ ```
663
+
664
+ A plain truthy value is no longer honoured, and the framework warns once naming the situation. Not
665
+ honouring it is the safe direction: the object is CHECKED rather than skipped — but "checked" means the
666
+ fields you set the flag to preserve are now stripped, so this is a change you will SEE.
667
+
668
+ > **Make this change together with the framework update, not before it.** `markRestrictionsChecked()`
669
+ > does not exist in 11.34.x, so a call-site change that lands first breaks the build. In vendor mode the
670
+ > call sites and the core arrive in one change anyway (`/lt-dev:backend:update-nest-server-core`); in npm
671
+ > mode, bump the dependency and the call sites in the same commit.
672
+
673
+ **How to find every site:**
674
+
675
+ ```bash
676
+ grep -rn "_objectAlreadyCheckedForRestrictions" --include="*.ts" src | grep -v "src/core/"
677
+ ```
678
+
679
+ The shape to look for is a service that ran `super.create(...)` / `super.update(...)` with
680
+ `force: true` and then marked the RESULT so the response interceptors would leave it alone. That is the
681
+ legitimate use — it is only the RECOGNITION that changed, never the intent.
682
+
683
+ ## 14. A boot warning for files in a multi-tenant deployment
684
+
685
+ The file stores are reached outside Mongoose (GridFS and S3 through their own drivers), so
686
+ `mongooseTenantPlugin` never scopes them — which is why `CoreFileController` / `CoreFileResolver`
687
+ carry `@SkipTenantCheck()`. The consequence is easy to miss and impossible to fix with configuration:
688
+ `file.downloadRoles: ['editor']` resolves `'editor'` against `user.roles`, a GLOBAL attribute, so
689
+ every holder of that role reaches EVERY tenant's files.
690
+
691
+ `CoreFileService` now warns at construction time when **all three** of these hold:
692
+
693
+ 1. `multiTenancy` is configured and enabled,
694
+ 2. at least one of `file.downloadRoles` / `uploadRoles` / `deleteRoles` contains a role other than
695
+ `ADMIN`,
696
+ 3. `CoreFileService.checkRights()` is NOT overridden.
697
+
698
+ Nothing else warns — the default admin-only gate is silent, and so is a project that has a per-file
699
+ rule. If you see it, write `tenantId` into the file metadata at upload time
700
+ (`serviceOptions.metadata`) and compare it in an overridden `checkRights()`; see
701
+ `src/core/modules/file/README.md` § Access control.
702
+
703
+ ## 15. The reference `checkRights()` now refuses an unrestricted LISTING
704
+
705
+ `findFileInfo()` consults the same per-file hook with `checkInputType: 'filterArgs'`, and the reference
706
+ rule — the shape every project copies out of `src/server/modules/file/file.service.ts` — narrowed only
707
+ `'id'` and `'filename'`. Everything else, listings included, was waved through.
708
+
709
+ Core exposes no listing endpoint, so this was never a live hole in the framework. It was a hole in the
710
+ shape projects copy, and it bites at the moment a project surfaces `findFileInfo()` — which is exactly
711
+ when nobody re-reads the rule. `CoreFileInfo` carries `filename`, `length`, `uploadDate` and the `id`,
712
+ so an unrestricted listing is a full INVENTORY of every upload, across tenants. For medical data the
713
+ filename frequently IS the content.
714
+
715
+ **The rule now refuses `'filterArgs'`**, because a yes/no hook cannot narrow a listing: it is asked once
716
+ for the whole query, not once per row. Writes (`'file'` / `'files'`) still fall through to the coarse
717
+ role gate — an upload has no owner to compare against yet.
718
+
719
+ **A per-user listing is expressed by FORCING the constraint server-side:**
720
+
721
+ ```typescript
722
+ this.fileService.findFileInfo(
723
+ { filter: { singleFilter: { field: 'metadata.ownerId', operator: ComparisonOperatorEnum.EQ,
724
+ value: String(currentUser.id) } } },
725
+ { force: true },
726
+ );
727
+ ```
728
+
729
+ Note what that is NOT: it does not inspect the caller's own `filterArgs` to check whether they are
730
+ already narrowed. **`filterArgs` is client-controlled**, so approving a filter shape means validating
731
+ attacker input — and any such check is one filter shape away from being wrong. Override the filter;
732
+ never approve it.
733
+
734
+ ### What you should do
735
+
736
+ - **If you copied the reference rule and expose `findFileInfo()`:** non-admins now get `null` instead of
737
+ a listing. Add the forced-filter call above wherever a per-user listing is intended.
738
+ - **If you never expose `findFileInfo()`:** nothing changes.
739
+
740
+ ## 16. New: `file.access` — the per-file rule as a declaration
741
+
742
+ Purely additive; the default (`'custom'`) is byte-for-byte the previous behaviour, including the absence
743
+ of any extra query.
744
+
745
+ The per-file rule is the only place a sentence like "…but only their own" can live, because that
746
+ sentence needs data and no role name carries data. The framework used to ship it as an `@example` to
747
+ copy — and the copy went wrong twice in this framework's own history, both times permissively:
748
+ `if (!options.currentUser) return true` (which is also what an anonymous request looks like), and
749
+ narrowing only `'id'` / `'filename'` so a listing was waved through. Neither reads as careless. That is
750
+ the argument for a preset.
751
+
752
+ | `file.access` | project class | own `checkRights()`? |
753
+ |---|---|---|
754
+ | `'custom'` (**default**) | anything the framework cannot guess — you write the rule | yes |
755
+ | `'public'` | open: anyone may read and write | no |
756
+ | `'authenticated'` | login-restricted: every signed-in user may use every file | no |
757
+ | `'owner'` | per-user: only the uploader, plus ADMIN | no |
758
+ | `'tenant'` | per-tenant: only the own validated tenant, plus ADMIN | no |
759
+
760
+ `'owner'` and `'tenant'` read `metadata.ownerId` / `metadata.tenantId`, and `CoreFileService` **stamps
761
+ them as it writes** while one of them is active — a preset that only READ those would be a rule about
762
+ data that does not exist, i.e. every file ADMIN-only. (That is not hypothetical: it is exactly the shape
763
+ TUS uploads had before this release.) Stamping never overrides a value the caller supplied, and stamps
764
+ nothing under `'custom'`, so no existing project's documents change.
765
+
766
+ Both presets: ADMIN is never locked out; writes fall through to `uploadRoles` / `deleteRoles`; a LISTING
767
+ is refused (a yes/no hook cannot narrow one — see §15); a missing user or a missing owner field fails
768
+ CLOSED. The tenant preset reads the VALIDATED tenant from `RequestContext`, the same source
769
+ `mongooseTenantPlugin` filters by, so a file decision and a database decision cannot disagree.
770
+
771
+ An unknown value resolves to `'owner'`, not `'custom'`: a typo means somebody believes they have an
772
+ ownership rule, and confirming that belief is the one error nobody can see from outside.
773
+
774
+ ### What you should do
775
+
776
+ - **Nothing** — the default changes no behaviour.
777
+ - **Adopting a preset?** Set `file.access`, then **backfill** `metadata.ownerId` / `metadata.tenantId`
778
+ on existing files. Until you do, they are ADMIN-only.
779
+ - **Already override `checkRights()`?** Keep doing that. The override wins and the preset never runs;
780
+ `'custom'` simply says so out loud.
781
+
782
+ ### The boot warning widened with it
783
+
784
+ `warnOnUnscopedFilesInTenantMode` is now `warnOnUndecidedFileAccess`, and it no longer requires
785
+ multi-tenancy. It warns when the role gate goes beyond `ADMIN` **and** neither `file.access` nor a
786
+ `checkRights()` override declares a policy — i.e. when nobody has decided. Declaring `'public'` or
787
+ `'authenticated'` silences it, which is the point: the warning is about the difference between a
788
+ decision and an omission. `downloadRoles: [S_USER]` with no rule leaks every file to every signed-in
789
+ user whether or not tenants exist, and file ids are enumerable.
790
+
791
+ ## New Exports
792
+
793
+ ```typescript
794
+ import {
795
+ markRestrictionsChecked, // the ONLY way to set the @Restricted opt-out now
796
+ hasRestrictionsCheckedMarker, // …and the way to read it
797
+ RESTRICTIONS_CHECKED_KEY,
798
+ resolveGuardRequest, // the shared guard request resolver (incl. subscriptions)
799
+ warnOnUndecidedFileAccess, // renamed from warnOnUnscopedFilesInTenantMode
800
+ resolveFileAccessPreset, // file.access resolution
801
+ decideFileAccess, // the pure per-file decision behind the presets
802
+ fileAccessNeedsRawDocument,
803
+ buildRequestContextAwareExecute, // the WS execute wrapper CoreModule installs
804
+ buildRequestContextAwareSubscribe, // the WS subscribe wrapper CoreModule installs
805
+ createRequestContextAwareExecute, // …the injectable forms, for a custom driver
806
+ createRequestContextAwareSubscribe,
807
+ getTenantContextResolver, // tenant resolution for transports without an Express request
808
+ setTenantContextResolver,
809
+ nestedTypeRegistry, // moved to an import-free leaf; re-exported where it was
810
+ resolveNestedType, // the declared type of a model property
811
+ TUS_OWNER_METADATA_KEY, // 'ltOwnerId'
812
+ } from '@lenne.tech/nest-server';
813
+ ```
814
+
815
+ ```typescript
816
+ import {
817
+ isSystemRole, // exact, case-sensitive — the RUNTIME rule the guards use
818
+ looksLikeSystemRole, // trimmed + case-insensitive — the STORAGE rule
819
+ SYSTEM_ROLE_PREFIX, // 's_'
820
+ SYSTEM_ROLE_REJECT_PATTERN, // @Matches pattern mirroring looksLikeSystemRole
821
+ mongooseSystemRolePlugin,
822
+ GLOBAL_ONLY_ROLES, // roles only the platform may grant — currently [ADMIN]
823
+ isGlobalOnlyRole, // exact — the RUNTIME rule
824
+ looksLikeGlobalOnlyRole, // trimmed + case-insensitive — the STORAGE rule
825
+ isForbiddenMembershipRole, // may this value be a tenant membership role at all?
826
+ resolveGlobalAndTenantRoles,// splits required roles by which source may answer them
827
+ } from '@lenne.tech/nest-server';
828
+ ```
829
+
830
+ The two predicates differ on purpose: runtime checks compare role strings exactly (so `'S_SELF'`
831
+ never granted anything and must not be treated as a system role by the guards), while the storage
832
+ question deserves the stricter, defensive answer.
833
+
834
+ ## Module Documentation
835
+
836
+ - Role system, `S_` prefix rules, `@Roles` vs `@Restricted`: [`.claude/rules/role-system.md`](../.claude/rules/role-system.md)
837
+ - Request lifecycle, validation pipe, interceptor chain: [`docs/REQUEST-LIFECYCLE.md`](../docs/REQUEST-LIFECYCLE.md)
838
+ - Reference implementation of a project user input: `src/server/modules/user/inputs/user.input.ts`
839
+
840
+ ## References
841
+
842
+ - Previous guide: [`11.34.0-to-11.34.1.md`](./11.34.0-to-11.34.1.md)
843
+ - `CoreUserInput`: `src/core/modules/user/inputs/core-user.input.ts`
844
+ - `mongooseSystemRolePlugin`: `src/core/common/plugins/mongoose-system-role.plugin.ts`
845
+ - Regression evidence: mutations `user-roles-accepts-system-roles` and
846
+ `non-dto-write-paths-accept-system-roles` in `tests/regression-mutations.json`