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