@lenne.tech/nest-server 11.34.0 → 11.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/helpers/validation-message.helper.d.ts +3 -0
- package/dist/core/common/helpers/validation-message.helper.js +41 -0
- package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
- 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/pipes/map-and-validate.pipe.js +16 -6
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- 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.0-to-11.34.1.md +132 -0
- 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/helpers/validation-message.helper.ts +83 -0
- 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/pipes/map-and-validate.pipe.ts +25 -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
|
@@ -88,6 +88,63 @@ If some files are genuinely public and others are not, do not solve it with a ro
|
|
|
88
88
|
record a visibility flag in the metadata and branch in `checkRights()`, or expose a separate public
|
|
89
89
|
route for exactly the public files and leave the core routes gated.
|
|
90
90
|
|
|
91
|
+
## Pick your project class first — the whole model is one dial with five settings
|
|
92
|
+
|
|
93
|
+
`file.access` is the per-file rule as a DECLARATION instead of code. Until 11.35.0 the framework shipped
|
|
94
|
+
that rule only as an `@example` to copy, and the copy went wrong twice in its own history — both times
|
|
95
|
+
permissively (`if (!currentUser) return true`, and waving `'filterArgs'` through). Decide which row you
|
|
96
|
+
are, then verify only that row.
|
|
97
|
+
|
|
98
|
+
| Project class | `file.access` | roles | own `checkRights()`? |
|
|
99
|
+
| -------------------------------------------------------------- | ------------------------ | ------------------------------------- | -------------------- |
|
|
100
|
+
| **Open** — anyone may read and write | `'public'` | `[RoleEnum.S_EVERYONE]` | no |
|
|
101
|
+
| **Login-restricted** — every signed-in user may use every file | `'authenticated'` | `[RoleEnum.S_USER]` | no |
|
|
102
|
+
| **Per-user** — only the uploader | `'owner'` | `[RoleEnum.S_USER]` | no |
|
|
103
|
+
| **Tenant-based** — only within one's own tenant | `'tenant'` | `[RoleEnum.S_USER]` or a project role | no |
|
|
104
|
+
| **Regulated** — explicit read right, explicit write right | `'custom'` (the default) | a narrow role | **yes** |
|
|
105
|
+
|
|
106
|
+
What the presets do, and what they deliberately do not:
|
|
107
|
+
|
|
108
|
+
- `'owner'` / `'tenant'` read `metadata.ownerId` / `metadata.tenantId`, which `CoreFileService` **stamps
|
|
109
|
+
as it writes** once one of them is active — so an upload through the service is authorizable with no
|
|
110
|
+
project code. ADMIN is never locked out, writes fall through to the role gate (an upload has no owner
|
|
111
|
+
to compare against yet), a LISTING is refused, and a missing user or a missing owner field FAILS
|
|
112
|
+
CLOSED.
|
|
113
|
+
- **Files written before you enabled the preset carry no such metadata and are therefore ADMIN-only.**
|
|
114
|
+
That is the fail-closed direction; a one-off backfill fixes it.
|
|
115
|
+
- `'public'` / `'authenticated'` add no data rule at all. They exist so that "no per-file rule" is a
|
|
116
|
+
DECISION in `config.env.ts` rather than an omission — and that is what silences the boot warning.
|
|
117
|
+
- **Declaring the class never widens the role gate.** `'public'` still needs
|
|
118
|
+
`downloadRoles: [S_EVERYONE]`. Two decisions, on purpose.
|
|
119
|
+
- **The last row is the point of the dial.** A regulated project has rights the framework cannot guess
|
|
120
|
+
(a read right, a write right, a case assignment), so it stays on `'custom'` and writes the rule — and
|
|
121
|
+
everything the presets do is then its checklist: cover `'id'` and `'filename'`, refuse `'filterArgs'`,
|
|
122
|
+
gate the writes, fail closed without a user, require the owner field to be present.
|
|
123
|
+
|
|
124
|
+
Two rules hold for every row:
|
|
125
|
+
|
|
126
|
+
1. **The coarse gate can grant but never exclude ADMIN** — both file classes carry a class-level
|
|
127
|
+
`@Roles(ADMIN)` and the guards union class + handler metadata.
|
|
128
|
+
2. **A per-file sentence needs data.** No role name can express "…but only their own" — which is why
|
|
129
|
+
there is a hook at all, and why the presets stamp the data they decide on.
|
|
130
|
+
|
|
131
|
+
## The threat model, in four sentences
|
|
132
|
+
|
|
133
|
+
Read this before the checklist — it is what the checklist is checking.
|
|
134
|
+
|
|
135
|
+
**File ids are not secrets, and they are ENUMERABLE.** A MongoDB ObjectId is 4 bytes of timestamp +
|
|
136
|
+
5 bytes of randomness generated ONCE PER PROCESS + a 3-byte incrementing counter. Every id minted by
|
|
137
|
+
one server process therefore shares the same random part, so a caller who obtains a single valid id —
|
|
138
|
+
their own upload is enough — knows that part and a counter reference point; the ids of files the same
|
|
139
|
+
process created nearby in time sit on adjacent counter values. There is also **no rate limit on the
|
|
140
|
+
file routes** (the framework's limiters cover auth, IAM and AI only), so nothing throttles walking
|
|
141
|
+
that range.
|
|
142
|
+
|
|
143
|
+
The consequence: **the role gate is a coarse audience filter, never a per-file secret.**
|
|
144
|
+
`downloadRoles: [S_USER]` without an overridden `checkRights()` means every signed-in user can read
|
|
145
|
+
every file, and enumeration makes that practically reachable rather than theoretical. If files are
|
|
146
|
+
personal or medical, a per-file rule is not optional.
|
|
147
|
+
|
|
91
148
|
## Verification Checklist
|
|
92
149
|
|
|
93
150
|
- [ ] `pnpm run build` succeeds
|
|
@@ -108,6 +165,40 @@ route for exactly the public files and leave the core routes gated.
|
|
|
108
165
|
- [ ] `OPTIONS /tus` answers without credentials (browser preflight)
|
|
109
166
|
- [ ] Avatars / images in the frontend still render for the roles that should see them
|
|
110
167
|
|
|
168
|
+
### Security checklist — the questions an audit will ask
|
|
169
|
+
|
|
170
|
+
- [ ] **Is the project class declared?** Either `file.access` names one, or `checkRights()` is
|
|
171
|
+
overridden. If `downloadRoles` goes beyond `ADMIN` and NEITHER is true, every holder of that
|
|
172
|
+
role reads every file by enumeration — and the framework warns at boot, in every deployment,
|
|
173
|
+
tenant or not. Declaring `'public'` / `'authenticated'` is a valid answer; leaving it unset is
|
|
174
|
+
not
|
|
175
|
+
- [ ] **On `file.access: 'owner'` / `'tenant'`: is old data backfilled?** Files uploaded before the
|
|
176
|
+
preset was enabled carry no `ownerId` / `tenantId` and stay ADMIN-only until they do
|
|
177
|
+
- [ ] **If you wrote your own rule, does it cover all four branches?** `'id'`, `'filename'`, `'filterArgs'` and the writes.
|
|
178
|
+
`'filename'` is not redundant (the presigned path authorizes on the by-name lookup alone, and
|
|
179
|
+
`deleteFileByName()` authorizes by name only), and `'filterArgs'` must be **refused** — a yes/no
|
|
180
|
+
hook cannot narrow a listing, so returning `true` there hands over a full inventory
|
|
181
|
+
- [ ] **Does the rule FAIL CLOSED on a missing `currentUser`?** `if (!options.currentUser) return true`
|
|
182
|
+
reads as "system call" but is also what an anonymous request looks like
|
|
183
|
+
- [ ] **Does it require the owner field to be PRESENT?** Without `!!raw?.metadata?.ownerId`, an
|
|
184
|
+
owner-less file compares `undefined` with `undefined` and matches
|
|
185
|
+
- [ ] **Is a per-user LISTING forced server-side?** Build the filter from `currentUser` and pass
|
|
186
|
+
`{ force: true }`. Never inspect the caller's own `filterArgs` to decide whether they are
|
|
187
|
+
already narrowed — that is validating client input
|
|
188
|
+
- [ ] **Multi-tenant: is `tenantId` in the metadata and compared?** The stores are reached outside
|
|
189
|
+
Mongoose, so `mongooseTenantPlugin` never scopes them and a role name cannot express a tenant
|
|
190
|
+
rule. Nothing else can do this for you
|
|
191
|
+
- [ ] **Is `s3.presignedDownloads` off** (it is by default) — or, if on, is the expiry short and the
|
|
192
|
+
audience genuinely "anyone who once held the link"? The URL works without a session, from any
|
|
193
|
+
IP, and cannot be revoked
|
|
194
|
+
- [ ] **Are `/files/*` and `/tus/*` rate-limited in the reverse proxy?** The framework does not
|
|
195
|
+
throttle them, and ids are enumerable
|
|
196
|
+
- [ ] **Do downloads go through the ID route, not the filename route?** Filenames are unique in no
|
|
197
|
+
store and are chosen by the uploader, so a name can be squatted; the by-name path resolves the
|
|
198
|
+
MOST RECENT file of that name
|
|
199
|
+
- [ ] **Does anything read files WITHOUT `CoreFileService`?** Direct GridFS or S3-SDK access bypasses
|
|
200
|
+
`checkRights()` entirely
|
|
201
|
+
|
|
111
202
|
## Common Mistakes
|
|
112
203
|
|
|
113
204
|
| Mistake | Symptom | Fix |
|
|
@@ -117,4 +208,7 @@ route for exactly the public files and leave the core routes gated.
|
|
|
117
208
|
| `downloadRoles: []` | Warning logged, default applied | Use a non-empty array; `[]` would read as "no roles required" and open the route |
|
|
118
209
|
| Expecting `metadata` back from `getFileInfo()` | `undefined` | Use `getRawFileInfo()` inside `checkRights()` |
|
|
119
210
|
| `downloadRoles: ['member']` with multiTenancy | Works from code, fails from `<img>` | Both file classes carry `@SkipTenantCheck()`; roles resolve against `user.roles`. Use `checkRights()` for tenant rules |
|
|
120
|
-
| Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
|
|
211
|
+
| Signed-in user can upload via TUS but not download | 403 on their own file | `tus.roles` and `file.downloadRoles` are separate. Add an owner to the metadata and authorize per file |
|
|
212
|
+
| Rule narrows only `'id'` / `'filename'` | `findFileInfo()` returns every file | Refuse `'filterArgs'`; force a per-user filter server-side with `{ force: true }` |
|
|
213
|
+
| Treating the file id as unguessable | Enumerable inventory | Ids share a per-process random part and an incrementing counter — authorize every read, do not rely on the id |
|
|
214
|
+
| Approving the caller's `filterArgs` as "narrowed" | Bypass via a different filter shape | `filterArgs` is client-controlled. Override the filter; never approve it |
|
|
@@ -189,6 +189,67 @@ streamed, because the filename route resolves an id and checks it again — but
|
|
|
189
189
|
by-name lookup alone and then redirects, and not for `deleteFileByName()`, which authorizes by name
|
|
190
190
|
only.
|
|
191
191
|
|
|
192
|
+
**A reused filename resolves to the MOST RECENT file (11.35.0+).** Filenames are unique in no store
|
|
193
|
+
and are client-supplied on both the multer and the tus path, so a by-name lookup is inherently
|
|
194
|
+
ambiguous — prefer the id routes. What must NOT be ambiguous is which of the candidates each by-name
|
|
195
|
+
path picks. Until 11.35.0 the GridFS driver got that wrong in the worst possible way:
|
|
196
|
+
`bucket.find({ filename })` answered natural order (the oldest document) while
|
|
197
|
+
`openDownloadStreamByName()` defaults to `revision: -1` (the newest), so `getFileInfoByName()` /
|
|
198
|
+
`getRawFileInfoByName()` authorized against one document and `getFileStreamByName()` /
|
|
199
|
+
`getBufferByName()` / `duplicateByName()` served another. An ownership rule approved the caller's own
|
|
200
|
+
file and handed over somebody else's bytes — across tenants, since the file stores carry no tenant
|
|
201
|
+
scope. All three drivers now resolve the most recent file (`uploadDate` desc, `_id` as tie-break) and
|
|
202
|
+
every by-name read path resolves a document and then reads **by id**.
|
|
203
|
+
|
|
204
|
+
One consequence worth knowing: `duplicateById()` keeps the source's filename and the copy carries no
|
|
205
|
+
`metadata` by design, so the copy WINS the name and an ownership rule keyed on `metadata.ownerId`
|
|
206
|
+
refuses it. Give the copy its own metadata or its own name.
|
|
207
|
+
|
|
208
|
+
### Before you write a rule: `file.access` may already be it
|
|
209
|
+
|
|
210
|
+
The four common project shapes are presets, so the rule below is only needed for the fifth — a project
|
|
211
|
+
whose rights the framework cannot guess.
|
|
212
|
+
|
|
213
|
+
| `file.access` | project class | own `checkRights()`? |
|
|
214
|
+
| -------------------- | ------------------------------------------------------ | -------------------- |
|
|
215
|
+
| `'public'` | open: anyone may read and write | no |
|
|
216
|
+
| `'authenticated'` | login-restricted: every signed-in user | no |
|
|
217
|
+
| `'owner'` | per-user: only the uploader (plus ADMIN) | no |
|
|
218
|
+
| `'tenant'` | per-tenant: only the own validated tenant (plus ADMIN) | no |
|
|
219
|
+
| `'custom'` (default) | anything else — you write the rule | yes |
|
|
220
|
+
|
|
221
|
+
`'owner'` and `'tenant'` read `metadata.ownerId` / `metadata.tenantId` and `CoreFileService` **stamps
|
|
222
|
+
them as it writes** while the preset is active, so an upload through the service is authorizable without
|
|
223
|
+
project code. Files written before you enabled it carry no such metadata and stay ADMIN-only — the
|
|
224
|
+
fail-closed direction, fixable with a one-off backfill. Declaring the class never widens the role gate,
|
|
225
|
+
and it never overrides an explicit `checkRights()` override: the override IS the rule.
|
|
226
|
+
|
|
227
|
+
Everything the presets do is also the checklist for a hand-written rule, so read on either way.
|
|
228
|
+
|
|
229
|
+
**Cover the `filterArgs` branch, and REFUSE it.** `findFileInfo()` consults the hook once for the whole
|
|
230
|
+
query, so no answer can mean "…but only their own files". Returning `true` — which the reference rule
|
|
231
|
+
used to do — hands a caller a full inventory of every upload: `CoreFileInfo` carries `filename`,
|
|
232
|
+
`length`, `uploadDate` and the `id`, and for medical data the filename frequently IS the content. Core
|
|
233
|
+
exposes no listing endpoint, so this only bites once a project surfaces `findFileInfo()`.
|
|
234
|
+
|
|
235
|
+
A per-user listing is expressed by FORCING the constraint server-side:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
this.fileService.findFileInfo(
|
|
239
|
+
{
|
|
240
|
+
filter: {
|
|
241
|
+
singleFilter: { field: 'metadata.ownerId', operator: ComparisonOperatorEnum.EQ, value: String(currentUser.id) },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{ force: true },
|
|
245
|
+
);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Note what that is NOT: it does not inspect the caller's own `filterArgs` to check whether they are
|
|
249
|
+
already narrowed. `filterArgs` is **client-controlled**, so approving a filter shape means validating
|
|
250
|
+
attacker input — and any such check is one filter shape away from being wrong. Override the filter;
|
|
251
|
+
never approve it.
|
|
252
|
+
|
|
192
253
|
**Never add `if (!options.currentUser) return true`.** It reads as "system-internal call, the guard
|
|
193
254
|
already decided" — but "no user in context" is also exactly what an **anonymous** request looks like.
|
|
194
255
|
While `downloadRoles` is narrower than `S_EVERYONE` the role gate turns those away first, so the
|
|
@@ -10,9 +10,12 @@ import { GridFSHelper } from '../../common/helpers/gridfs.helper';
|
|
|
10
10
|
import { check } from '../../common/helpers/input.helper';
|
|
11
11
|
import { prepareOutput } from '../../common/helpers/service.helper';
|
|
12
12
|
import { ConfigService } from '../../common/services/config.service';
|
|
13
|
+
import { RequestContext } from '../../common/services/request-context.service';
|
|
13
14
|
import { CoreS3Service } from '../../common/services/core-s3.service';
|
|
14
15
|
import { MaybePromise } from '../../common/types/maybe-promise.type';
|
|
15
16
|
import { CoreFileInfo } from './core-file-info.model';
|
|
17
|
+
import { decideFileAccess, fileAccessNeedsRawDocument, resolveFileAccessPreset } from './file-access.helper';
|
|
18
|
+
import { warnOnUndecidedFileAccess } from './file-roles.config';
|
|
16
19
|
import { FileServiceOptions } from './interfaces/file-service-options.interface';
|
|
17
20
|
import { FileUploadSource } from './interfaces/file-upload.interface';
|
|
18
21
|
import {
|
|
@@ -83,6 +86,17 @@ export abstract class CoreFileService {
|
|
|
83
86
|
this.storageResolution = resolveFileStorage(ConfigService.configFastButReadOnly);
|
|
84
87
|
assertFileStorageAvailable(this.storageResolution, this.isStorageAvailable(this.storageResolution.driver));
|
|
85
88
|
logFileStorage(this.storageResolution);
|
|
89
|
+
|
|
90
|
+
// Warn when the gate is open and nothing declares the per-file policy — see the helper for why
|
|
91
|
+
// the conditions are this narrow and why it is not gated on multi-tenancy. Checked here rather than in `CoreModule.forRoot()` because only
|
|
92
|
+
// an instance can answer whether `checkRights()` was overridden: the base implementation is a
|
|
93
|
+
// fixed function, so an identity comparison against it is exact and needs no naming convention.
|
|
94
|
+
const config = ConfigService.configFastButReadOnly;
|
|
95
|
+
warnOnUndecidedFileAccess({
|
|
96
|
+
fileConfig: config?.file,
|
|
97
|
+
hasPerFileRule: this.checkRights !== CoreFileService.prototype.checkRights,
|
|
98
|
+
multiTenancyEnabled: !!config?.multiTenancy && config.multiTenancy.enabled !== false,
|
|
99
|
+
});
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
/**
|
|
@@ -148,12 +162,16 @@ export abstract class CoreFileService {
|
|
|
148
162
|
}
|
|
149
163
|
const { createReadStream, filename, mimetype } = await file;
|
|
150
164
|
const readStream = createReadStream();
|
|
165
|
+
// Resolved ONCE for all three driver branches: under an `'owner'` / `'tenant'` preset this adds
|
|
166
|
+
// the very fields the preset decides on, so an upload through this service is authorizable
|
|
167
|
+
// without any project code. Under the default preset it is `serviceOptions.metadata` verbatim.
|
|
168
|
+
const metadata = this.accessMetadata(serviceOptions);
|
|
151
169
|
if (this.filesystemStorage) {
|
|
152
170
|
const fsFileInfo = await FilesystemFileHelper.writeFile(this.filesystemDir, this.filesystemFiles, {
|
|
153
171
|
body: readStream,
|
|
154
172
|
contentType: mimetype,
|
|
155
173
|
filename,
|
|
156
|
-
...(
|
|
174
|
+
...(metadata ? { metadata } : {}),
|
|
157
175
|
});
|
|
158
176
|
return this.prepareOutput(fsFileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
159
177
|
}
|
|
@@ -165,14 +183,14 @@ export abstract class CoreFileService {
|
|
|
165
183
|
buffer: await streamToBuffer(readStream),
|
|
166
184
|
contentType: mimetype,
|
|
167
185
|
filename,
|
|
168
|
-
...(
|
|
186
|
+
...(metadata ? { metadata } : {}),
|
|
169
187
|
});
|
|
170
188
|
return this.prepareOutput(s3FileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
171
189
|
}
|
|
172
190
|
const fileInfo = await GridFSHelper.writeFileFromStream(this.files, readStream, {
|
|
173
191
|
contentType: mimetype,
|
|
174
192
|
filename,
|
|
175
|
-
...(
|
|
193
|
+
...(metadata ? { metadata } : {}),
|
|
176
194
|
});
|
|
177
195
|
return this.prepareOutput(fileInfo as unknown as CoreFileInfo, serviceOptions);
|
|
178
196
|
}
|
|
@@ -537,6 +555,16 @@ export abstract class CoreFileService {
|
|
|
537
555
|
|
|
538
556
|
/**
|
|
539
557
|
* Get file stream (for big files) via filename
|
|
558
|
+
*
|
|
559
|
+
* Every branch resolves a DOCUMENT first and reads by its id — including the GridFS one, which
|
|
560
|
+
* used to fall through to `openDownloadStreamByName()`. That call defaults to `revision: -1` (the
|
|
561
|
+
* newest file of that name) while the by-name METADATA lookup answered the oldest, so with two
|
|
562
|
+
* files sharing a name this method streamed bytes belonging to a different document than the one
|
|
563
|
+
* `checkRights()` had just been asked about. An ownership rule then approved the caller's own file
|
|
564
|
+
* and handed over somebody else's — across tenants, since the file stores carry no tenant scope.
|
|
565
|
+
*
|
|
566
|
+
* `findFileByName()` is now newest-first in all three stores, so the bytes a caller receives are
|
|
567
|
+
* unchanged; what changed is that the document authorization inspected is the one being served.
|
|
540
568
|
*/
|
|
541
569
|
async getFileStreamByName(filename: string, serviceOptions?: FileServiceOptions): Promise<Readable> {
|
|
542
570
|
if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
|
|
@@ -550,7 +578,13 @@ export abstract class CoreFileService {
|
|
|
550
578
|
if (fsFileInfo) {
|
|
551
579
|
return FilesystemFileHelper.getStream(this.filesystemDir, fsFileInfo._id);
|
|
552
580
|
}
|
|
553
|
-
|
|
581
|
+
const gridFsInfo = await GridFSHelper.findFileByName(this.files, filename);
|
|
582
|
+
if (!gridFsInfo) {
|
|
583
|
+
// Unchanged answer for an unknown name: GridFS is the terminal store and reports the miss
|
|
584
|
+
// itself, asynchronously on the stream, which the controller turns into a 404.
|
|
585
|
+
return GridFSHelper.openDownloadStreamByName(this.files, filename);
|
|
586
|
+
}
|
|
587
|
+
return GridFSHelper.openDownloadStream(this.files, gridFsInfo._id);
|
|
554
588
|
}
|
|
555
589
|
|
|
556
590
|
/**
|
|
@@ -584,7 +618,14 @@ export abstract class CoreFileService {
|
|
|
584
618
|
if (fsFileInfo) {
|
|
585
619
|
return FilesystemFileHelper.getBuffer(this.filesystemDir, fsFileInfo._id);
|
|
586
620
|
}
|
|
587
|
-
|
|
621
|
+
// By id, for the same reason as getFileStreamByName(): reading by NAME here would pick the
|
|
622
|
+
// newest revision while checkRights() was asked about whichever document findFileByName()
|
|
623
|
+
// answered.
|
|
624
|
+
const gridFsInfo = await GridFSHelper.findFileByName(this.files, filename);
|
|
625
|
+
if (!gridFsInfo) {
|
|
626
|
+
return await GridFSHelper.readFileToBuffer(this.files, { filename });
|
|
627
|
+
}
|
|
628
|
+
return await GridFSHelper.readFileToBuffer(this.files, { _id: gridFsInfo._id });
|
|
588
629
|
}
|
|
589
630
|
|
|
590
631
|
/**
|
|
@@ -766,15 +807,43 @@ export abstract class CoreFileService {
|
|
|
766
807
|
* `tests/file-ownership.e2e-spec.ts`. Prefer reading it there over copying
|
|
767
808
|
* from here.
|
|
768
809
|
*
|
|
810
|
+
* **Cover the `filterArgs` branch, and REFUSE it.** `findFileInfo()` consults this hook ONCE for the
|
|
811
|
+
* whole query, so there is no answer that means "…but only their own files". Returning `true` hands a
|
|
812
|
+
* caller a full inventory of every upload — `CoreFileInfo` carries `filename`, `length`, `uploadDate`
|
|
813
|
+
* and the `id`, and for medical data the filename frequently IS the content. Core exposes no listing
|
|
814
|
+
* endpoint, so this only bites once a project surfaces `findFileInfo()` — which is exactly when
|
|
815
|
+
* nobody re-reads the rule.
|
|
816
|
+
*
|
|
817
|
+
* A per-user listing is expressed by FORCING the constraint server-side:
|
|
818
|
+
*
|
|
819
|
+
* ```typescript
|
|
820
|
+
* this.fileService.findFileInfo(
|
|
821
|
+
* { filter: { singleFilter: { field: 'metadata.ownerId', operator: ComparisonOperatorEnum.EQ,
|
|
822
|
+
* value: String(currentUser.id) } } },
|
|
823
|
+
* { force: true },
|
|
824
|
+
* );
|
|
825
|
+
* ```
|
|
826
|
+
*
|
|
827
|
+
* Note what that is NOT: it does not inspect the caller's own `filterArgs` to check whether they are
|
|
828
|
+
* already narrowed. `filterArgs` is CLIENT-CONTROLLED, so approving a filter shape means validating
|
|
829
|
+
* attacker input, and any such check is one filter shape away from being wrong. **Override the
|
|
830
|
+
* filter; never approve it.**
|
|
831
|
+
*
|
|
769
832
|
* @example
|
|
770
833
|
* ```typescript
|
|
771
834
|
* protected override async checkRights(
|
|
772
835
|
* input: any,
|
|
773
836
|
* options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
774
837
|
* ): Promise<boolean> {
|
|
775
|
-
*
|
|
838
|
+
* // Writes stay on the coarse gate: an upload has no owner to compare against yet.
|
|
839
|
+
* if (options?.force || options?.checkInputType === 'file' || options?.checkInputType === 'files') {
|
|
776
840
|
* return true;
|
|
777
841
|
* }
|
|
842
|
+
* // A LISTING cannot be narrowed by a yes/no hook — it is asked once for the whole query, not
|
|
843
|
+
* // once per row — so refuse the unrestricted one. See the note above.
|
|
844
|
+
* if (options?.checkInputType === 'filterArgs') {
|
|
845
|
+
* return false;
|
|
846
|
+
* }
|
|
778
847
|
* if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
779
848
|
* return true;
|
|
780
849
|
* }
|
|
@@ -788,11 +857,99 @@ export abstract class CoreFileService {
|
|
|
788
857
|
* }
|
|
789
858
|
* ```
|
|
790
859
|
*/
|
|
860
|
+
/**
|
|
861
|
+
* The metadata a file is written with — the caller's, plus whatever the active preset decides on.
|
|
862
|
+
*
|
|
863
|
+
* A preset that only READ `metadata.ownerId` / `metadata.tenantId` would be a rule about data that
|
|
864
|
+
* does not exist: every file ADMIN-only. That is not hypothetical — it is exactly the shape TUS
|
|
865
|
+
* uploads had before 11.35.0, and the report came back from downstream rather than from a test.
|
|
866
|
+
*
|
|
867
|
+
* Three properties, each of which is a decision:
|
|
868
|
+
*
|
|
869
|
+
* - **Only under a preset that needs it.** With `file.access` unset (or `'public'` /
|
|
870
|
+
* `'authenticated'`) this returns `serviceOptions.metadata` untouched, so no existing project's
|
|
871
|
+
* documents grow a field.
|
|
872
|
+
* - **Never overrides what the caller supplied.** A project that records ownership itself — or
|
|
873
|
+
* deliberately attributes a file to someone else, as an admin provisioning flow does — keeps
|
|
874
|
+
* winning. `TUS_OWNER_METADATA_KEY` is the opposite case and overwrites on purpose, because there
|
|
875
|
+
* the value arrives from the CLIENT.
|
|
876
|
+
* - **Stamps nothing it cannot know.** An anonymous upload gets no owner, which leaves the file
|
|
877
|
+
* ADMIN-only rather than owned by `undefined`.
|
|
878
|
+
*
|
|
879
|
+
* `protected` so a project can add its own dimension (a project id, a case number) by overriding and
|
|
880
|
+
* calling `super`.
|
|
881
|
+
*/
|
|
882
|
+
protected accessMetadata(serviceOptions?: FileServiceOptions): Record<string, any> | undefined {
|
|
883
|
+
const preset = resolveFileAccessPreset(ConfigService.configFastButReadOnly?.file);
|
|
884
|
+
if (preset !== 'owner' && preset !== 'tenant') {
|
|
885
|
+
return serviceOptions?.metadata;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const stamped: Record<string, any> = { ...serviceOptions?.metadata };
|
|
889
|
+
const ownerId = serviceOptions?.currentUser?.id;
|
|
890
|
+
if (stamped.ownerId === undefined && ownerId !== undefined && ownerId !== null && ownerId !== '') {
|
|
891
|
+
stamped.ownerId = String(ownerId);
|
|
892
|
+
}
|
|
893
|
+
if (preset === 'tenant') {
|
|
894
|
+
// The VALIDATED tenant, same source the read decision uses.
|
|
895
|
+
const tenantId = RequestContext.get()?.tenantId;
|
|
896
|
+
if (stamped.tenantId === undefined && tenantId) {
|
|
897
|
+
stamped.tenantId = tenantId;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return Object.keys(stamped).length ? stamped : undefined;
|
|
901
|
+
}
|
|
902
|
+
|
|
791
903
|
protected checkRights(
|
|
792
|
-
|
|
793
|
-
|
|
904
|
+
input: any,
|
|
905
|
+
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
794
906
|
): MaybePromise<boolean> {
|
|
795
|
-
return
|
|
907
|
+
// `MaybePromise<boolean>`, NOT `Promise<boolean>`, and not `async`. Narrowing the declared return
|
|
908
|
+
// type would break every consumer whose override returns a plain `boolean` — which the old
|
|
909
|
+
// signature explicitly invited — and TypeScript rejects that at the OVERRIDE, in their code, with
|
|
910
|
+
// an error that points at their file rather than at this change. `async` forces `Promise<T>`, so
|
|
911
|
+
// the async work lives in a separate method instead.
|
|
912
|
+
return this.resolveAccessPreset(input, options);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The async half of {@link checkRights}, split out only so the public seam can keep its
|
|
917
|
+
* `MaybePromise<boolean>` signature (see there).
|
|
918
|
+
*/
|
|
919
|
+
private async resolveAccessPreset(
|
|
920
|
+
input: any,
|
|
921
|
+
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
922
|
+
): Promise<boolean> {
|
|
923
|
+
const preset = resolveFileAccessPreset(ConfigService.configFastButReadOnly?.file);
|
|
924
|
+
|
|
925
|
+
// `'custom'` is the default and returns true for every input — byte-for-byte the pre-11.35.0
|
|
926
|
+
// behaviour, including the absence of any lookup. An existing project sees no change at all.
|
|
927
|
+
if (preset === 'custom') {
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// The lookup is skipped where the decision cannot use it: a forced (system) call, a write, a
|
|
932
|
+
// listing, and the two blanket presets. So enabling a preset never adds a query to a path that
|
|
933
|
+
// does not read a document — which matters most for `force: true`, the idiom internal callers use
|
|
934
|
+
// on hot paths precisely because they have already been decided about.
|
|
935
|
+
const raw =
|
|
936
|
+
!options?.force && fileAccessNeedsRawDocument(preset, options?.checkInputType)
|
|
937
|
+
? options?.checkInputType === 'id'
|
|
938
|
+
? await this.getRawFileInfo(input)
|
|
939
|
+
: await this.getRawFileInfoByName(input)
|
|
940
|
+
: undefined;
|
|
941
|
+
|
|
942
|
+
return decideFileAccess({
|
|
943
|
+
checkInputType: options?.checkInputType,
|
|
944
|
+
currentUser: options?.currentUser,
|
|
945
|
+
force: options?.force,
|
|
946
|
+
preset,
|
|
947
|
+
raw,
|
|
948
|
+
// The VALIDATED tenant, from the same source `mongooseTenantPlugin` filters by — never a raw
|
|
949
|
+
// header, and never `serviceOptions`, so a file decision and a database decision cannot
|
|
950
|
+
// disagree about which tenant the request is in.
|
|
951
|
+
tenantId: RequestContext.get()?.tenantId,
|
|
952
|
+
});
|
|
796
953
|
}
|
|
797
954
|
|
|
798
955
|
/**
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four project classes as ONE dial — `file.access`.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS: the per-file rule is the only place a sentence like "…but only their own" can
|
|
5
|
+
* live, because that sentence needs data and no role name carries data. Until 11.35.0 the framework
|
|
6
|
+
* shipped that rule as an `@example` to copy, and the copy went wrong twice in this repository's own
|
|
7
|
+
* history — both times in the permissive direction:
|
|
8
|
+
*
|
|
9
|
+
* - `if (!options.currentUser) return true` reads as "system-internal call", and is also exactly what
|
|
10
|
+
* an ANONYMOUS request looks like;
|
|
11
|
+
* - narrowing only `'id'` / `'filename'` waves `'filterArgs'` through, which hands over a full
|
|
12
|
+
* inventory of every upload the moment a project surfaces `findFileInfo()`.
|
|
13
|
+
*
|
|
14
|
+
* Neither mistake is careless — both read as correct. That is the argument for a preset: the rule is
|
|
15
|
+
* always the same handful of sentences, so the framework should own them and the project should
|
|
16
|
+
* declare which one it wants.
|
|
17
|
+
*
|
|
18
|
+
* | `file.access` | project class |
|
|
19
|
+
* |-------------------|----------------------------------------------------------------|
|
|
20
|
+
* | `'custom'` | the project writes its own rule — **the default**; the framework abstains entirely |
|
|
21
|
+
* | `'public'` | open: anyone may read and write, the role gate is the whole policy |
|
|
22
|
+
* | `'authenticated'` | login-restricted: every signed-in user may use every file |
|
|
23
|
+
* | `'owner'` | per-user: only the uploader (plus ADMIN) |
|
|
24
|
+
* | `'tenant'` | per-tenant: only within one's own validated tenant (plus ADMIN) |
|
|
25
|
+
*
|
|
26
|
+
* NOTHING CHANGES FOR AN EXISTING PROJECT. `'custom'` is the default and returns `true` for every
|
|
27
|
+
* input, which is byte-for-byte what `CoreFileService.checkRights()` did before. A project that
|
|
28
|
+
* overrides `checkRights()` keeps full control regardless of the setting — the override IS the rule,
|
|
29
|
+
* and the preset never runs.
|
|
30
|
+
*
|
|
31
|
+
* THE DECISION IS A PURE FUNCTION. It takes an already-loaded raw document instead of a service, so
|
|
32
|
+
* every row of the table can be stated in a unit test without a database, and so the service's only
|
|
33
|
+
* remaining job is to decide whether a lookup is needed at all
|
|
34
|
+
* ({@link fileAccessNeedsRawDocument}) — a listing and a write never need one.
|
|
35
|
+
*
|
|
36
|
+
* DELIBERATELY A LEAF: it imports the role enum and nothing else. `file-roles.helper` already had to
|
|
37
|
+
* be split for the same reason (it imports the endpoint classes, which inject `CoreFileService`), and
|
|
38
|
+
* this file is read FROM the service. See `.claude/rules/architecture.md` → "DI Token Placement".
|
|
39
|
+
*/
|
|
40
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
41
|
+
|
|
42
|
+
/** Which of the four project classes this deployment is. */
|
|
43
|
+
export type FileAccessPreset = 'authenticated' | 'custom' | 'owner' | 'public' | 'tenant';
|
|
44
|
+
|
|
45
|
+
/** The input types `CoreFileService.checkRights()` distinguishes. */
|
|
46
|
+
type CheckInputType = 'file' | 'filename' | 'files' | 'filterArgs' | 'id';
|
|
47
|
+
|
|
48
|
+
const PRESETS: readonly FileAccessPreset[] = ['authenticated', 'custom', 'owner', 'public', 'tenant'] as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which preset a project asked for.
|
|
52
|
+
*
|
|
53
|
+
* An UNKNOWN value resolves to `'owner'`, the strictest of the data-driven presets — never to
|
|
54
|
+
* `'custom'`. A typo (`access: 'onwer'`) means somebody believes they have an ownership rule; giving
|
|
55
|
+
* them "the framework abstains" would confirm that belief and be wrong in the one direction that
|
|
56
|
+
* cannot be noticed from the outside. A too-strict answer surfaces as a 404 on the first request,
|
|
57
|
+
* which is a bug report; a too-permissive one surfaces as an incident.
|
|
58
|
+
*
|
|
59
|
+
* @default 'custom'
|
|
60
|
+
*/
|
|
61
|
+
export function resolveFileAccessPreset(fileConfig?: { access?: FileAccessPreset }): FileAccessPreset {
|
|
62
|
+
const configured = fileConfig?.access;
|
|
63
|
+
if (configured === undefined || configured === null) {
|
|
64
|
+
return 'custom';
|
|
65
|
+
}
|
|
66
|
+
return PRESETS.includes(configured) ? configured : 'owner';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Does the decision for this (preset, input type) pair read the stored document?
|
|
71
|
+
*
|
|
72
|
+
* Asked by the service so it can skip the metadata lookup where the answer cannot use it. A write has
|
|
73
|
+
* no document yet, a listing is not about one document, and the two blanket presets answer without
|
|
74
|
+
* looking — loading anyway would add a query per call for nothing.
|
|
75
|
+
*/
|
|
76
|
+
export function fileAccessNeedsRawDocument(preset: FileAccessPreset, checkInputType?: CheckInputType): boolean {
|
|
77
|
+
if (preset !== 'owner' && preset !== 'tenant') {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return checkInputType === 'id' || checkInputType === 'filename';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Decide one file access question.
|
|
85
|
+
*
|
|
86
|
+
* @param options.raw the stored metadata document, loaded by the caller when
|
|
87
|
+
* {@link fileAccessNeedsRawDocument} says so. `null` means "no such file", which must not be
|
|
88
|
+
* confused with "no restriction".
|
|
89
|
+
* @param options.tenantId the VALIDATED tenant of the current request (`RequestContext.tenantId`),
|
|
90
|
+
* never a raw header — an unvalidated header would be worse than no tenant at all.
|
|
91
|
+
*/
|
|
92
|
+
export function decideFileAccess(options: {
|
|
93
|
+
checkInputType?: CheckInputType;
|
|
94
|
+
currentUser?: { hasRole?: (roles: string[]) => boolean; id?: any };
|
|
95
|
+
force?: boolean;
|
|
96
|
+
preset: FileAccessPreset;
|
|
97
|
+
raw?: null | Record<string, any>;
|
|
98
|
+
tenantId?: string;
|
|
99
|
+
}): boolean {
|
|
100
|
+
const { checkInputType, currentUser, force, preset, raw, tenantId } = options;
|
|
101
|
+
|
|
102
|
+
// A system caller says so explicitly. This is the documented alternative to the
|
|
103
|
+
// `if (!currentUser) return true` shortcut: the exemption is stated at the call site, in the diff,
|
|
104
|
+
// rather than inferred from an absence that an anonymous request produces too.
|
|
105
|
+
if (force) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The framework abstains — identical to the pre-11.35.0 base implementation.
|
|
110
|
+
if (preset === 'custom' || preset === 'public') {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (preset === 'authenticated') {
|
|
115
|
+
// Deliberately independent of the role gate: it still holds when `downloadRoles` is `S_EVERYONE`,
|
|
116
|
+
// which is the case where a project believes the login requirement is somewhere else.
|
|
117
|
+
return !!currentUser?.id;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ADMIN is platform authority and is never locked out — the same union the role guards apply.
|
|
121
|
+
if (currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Writes stay on the coarse role gate (`file.uploadRoles` / `deleteRoles`): an upload has no stored
|
|
126
|
+
// document yet, so there is nothing to compare against. Ownership of the RESULT is established by
|
|
127
|
+
// the metadata the service stamps as it writes.
|
|
128
|
+
if (checkInputType === 'file' || checkInputType === 'files') {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A LISTING cannot be narrowed by a yes/no answer — this hook is asked once for the whole query,
|
|
133
|
+
// not once per row. Refusing is the only honest answer; a per-user listing is expressed by forcing
|
|
134
|
+
// the filter server-side and passing `force: true`. See CoreFileService.checkRights().
|
|
135
|
+
if (checkInputType === 'filterArgs') {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (preset === 'owner') {
|
|
140
|
+
// Requiring the field to be PRESENT is load-bearing twice over: without it an owner-less file
|
|
141
|
+
// compares `String(undefined)` with `String(undefined)` and matches, so every unowned file would
|
|
142
|
+
// be readable by every caller whose id is also missing — i.e. by anonymous requests.
|
|
143
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(currentUser?.id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 'tenant': both sides must be present. No tenant in context is a cron job on the HTTP path and an
|
|
147
|
+
// unresolvable header on the WebSocket path; neither may read tenant-scoped bytes.
|
|
148
|
+
return !!tenantId && !!raw?.metadata?.tenantId && String(raw.metadata.tenantId) === String(tenantId);
|
|
149
|
+
}
|
|
@@ -74,17 +74,25 @@ export async function findMetadataById(
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Find one metadata document by filename.
|
|
77
|
+
* Find one metadata document by filename — the MOST RECENT file with that name.
|
|
78
78
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
79
|
+
* Filenames are unique in NO store and are client-supplied on both the multer and the tus path, so
|
|
80
|
+
* a by-name lookup is inherently ambiguous: prefer the id lookup wherever the caller has an id.
|
|
81
|
+
* What must not be ambiguous is WHICH of the candidates every by-name path picks — an ownership
|
|
82
|
+
* rule reads one document and the download must serve that same one.
|
|
83
|
+
*
|
|
84
|
+
* A bare `findOne({ filename })` returns natural order, which is neither documented nor stable
|
|
85
|
+
* under compaction. Newest-first matches GridFS's own by-name revision semantics
|
|
86
|
+
* (`openDownloadStreamByName` defaults to `revision: -1`), so all three storage drivers answer the
|
|
87
|
+
* same question the same way; `_id` breaks the tie for two files written in one millisecond.
|
|
82
88
|
*/
|
|
83
89
|
export async function findMetadataByName(
|
|
84
90
|
collection: FileCollection,
|
|
85
91
|
filename: string,
|
|
86
92
|
): Promise<FileMetadataInfo | null> {
|
|
87
|
-
|
|
93
|
+
// `uploadDate` FIRST — MongoDB applies sort keys in document order, so leading with `_id` would
|
|
94
|
+
// make the tie-break the primary key.
|
|
95
|
+
return (await collection.findOne({ filename }, { sort: { uploadDate: -1, _id: -1 } })) as FileMetadataInfo | null;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
/** Find metadata documents by filter */
|