@lenne.tech/nest-server 11.35.0 → 11.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/configurable-features.md +1 -1
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/helpers/file.helper.js +3 -2
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/modules/file/core-file-access-audit.initializer.d.ts +13 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
- package/dist/core/modules/file/file-roles.config.d.ts +21 -0
- package/dist/core/modules/file/file-roles.config.js +51 -2
- package/dist/core/modules/file/file-roles.config.js.map +1 -1
- package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
- package/dist/core/modules/file/file-roles.helper.js +10 -16
- package/dist/core/modules/file/file-roles.helper.js.map +1 -1
- package/dist/core.module.js +3 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.34.x-to-11.35.x.md +17 -3
- package/migration-guides/11.35.0-to-11.35.1.md +152 -0
- package/package.json +1 -1
- package/src/core/common/helpers/file.helper.ts +11 -5
- package/src/core/modules/file/README.md +10 -0
- package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -0
- package/src/core/modules/file/file-roles.config.ts +156 -1
- package/src/core/modules/file/file-roles.helper.ts +15 -22
- package/src/core.module.ts +10 -2
- package/src/index.ts +4 -0
|
@@ -49,11 +49,12 @@ Then run the one-time audit in §3.
|
|
|
49
49
|
them is half-wired if you take one side and not the other, and a half-wired enforcement layer is worse
|
|
50
50
|
than none because it reads as protection.
|
|
51
51
|
|
|
52
|
-
**
|
|
52
|
+
**Nine new files** (eight rows — the two file-module files ship as one pair):
|
|
53
53
|
|
|
54
54
|
| File | Belongs with |
|
|
55
55
|
|------|--------------|
|
|
56
56
|
| `common/plugins/mongoose-system-role.plugin.ts` | `common/enums/role.enum.ts` — the plugin is the unconditional layer, the enum holds the predicates (§1) |
|
|
57
|
+
| `modules/tenant/core-role-scope.registry.ts` | `modules/tenant/core-tenant.helpers.ts` + `modules/tenant/core-tenant.module.ts` + `common/enums/role.enum.ts` — the registry holds the global-vs-tenant split the guards resolve against, `core-tenant.helpers.ts` feeds it from configuration, and `core-tenant.module.ts` is where `forRoot()` registers that source and runs `assertRoleVocabularyIsCoherent()`. Without the module wiring the registry stays empty, every role resolves to `RoleScope.UNKNOWN`, and the boot check that refuses an incoherent vocabulary never runs (§5) |
|
|
57
58
|
| `common/decorators/nested-type.registry.ts` | `decorators/restricted.decorator.ts` + `decorators/unified-field.decorator.ts` — the registry is written by one and read by the other (§8) |
|
|
58
59
|
| `common/decorators/restrictions-checked.marker.ts` | `decorators/restricted.decorator.ts` + both interceptors — all four read the marker; an old truthy check anywhere reopens the bypass (§13) |
|
|
59
60
|
| `common/helpers/graphql-ws-context.helper.ts` | `common/services/core-tenant-context.registry.ts` + `core.module.ts` + `modules/tenant/core-tenant.guard.ts` — the context is useless without the resolver, and unreached without the `core.module.ts` wiring (§7) |
|
|
@@ -66,8 +67,21 @@ than none because it reads as protection.
|
|
|
66
67
|
`common/plugins/mongoose-tenant.plugin.ts`, `common/services/core-s3.service.ts`,
|
|
67
68
|
`modules/file/{core-file.service,file-metadata.helper,file-roles.helper}.ts`,
|
|
68
69
|
`modules/tus/{core-tus.service,tus.constants,index}.ts`, `modules/user/inputs/core-user.input.ts`,
|
|
69
|
-
`modules/user/core-user.service.ts`,
|
|
70
|
-
|
|
70
|
+
`modules/user/core-user.service.ts`,
|
|
71
|
+
`modules/tenant/{core-tenant.helpers,core-tenant.module,core-tenant.service}.ts`, and the three
|
|
72
|
+
module READMEs (`file`, `tenant`, `tus`) plus `modules/file/INTEGRATION-CHECKLIST.md`.
|
|
73
|
+
|
|
74
|
+
`core-tenant.module.ts` and `core-tenant.service.ts` are easy to miss and both carry enforcement:
|
|
75
|
+
`forRoot()` registers the role-scope source and asserts the vocabulary is coherent, and
|
|
76
|
+
`core-tenant.service.ts` gains `assertAssignableMembershipRole()`, which refuses a membership role
|
|
77
|
+
named after a system (`s_*`) or global-only role at assignment time (§5).
|
|
78
|
+
|
|
79
|
+
The remaining changed files — `common/enums/role.enum.ts`,
|
|
80
|
+
`common/decorators/{restricted,unified-field}.decorator.ts`, both interceptors
|
|
81
|
+
(`check-security`, `response-model`), and all three guards
|
|
82
|
+
(`modules/auth/guards/roles.guard.ts`, `modules/better-auth/better-auth-roles.guard.ts`,
|
|
83
|
+
`modules/tenant/core-tenant.guard.ts`) — are the counterparts already named in the "Belongs with"
|
|
84
|
+
column above. Take them with the new file they pair with.
|
|
71
85
|
|
|
72
86
|
**Also worth taking:** `src/test/test.helper.ts` (subscription `connectionParams`, and a refused
|
|
73
87
|
subscription now rejects instead of delivering an indistinguishable `null` — without it you cannot test
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Migration Guide: 11.35.0 → 11.35.1
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None in signatures. One **client-visible** change: a refused upload now answers **400** instead of **500**, with Nest's standard error envelope instead of an internal-error one (§1) |
|
|
8
|
+
| **Bugfixes** | `multerFileFilter` rejections finally map to a 4xx (§1). Seven documented helper names are reachable from the package root for the first time (§2). The file-access boot warning can no longer be fooled by a subclassed controller (§3) |
|
|
9
|
+
| **Migration Effort** | Nothing to configure. Read §1 if a frontend, a test, or an alerting rule branches on the status or body of a rejected upload. **Read §3 if you subclass `CoreFileController` / `CoreFileResolver` and re-declare a member** — you may see a new warning at startup, and it is probably telling you something true |
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick Migration
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm update @lenne.tech/nest-server@11.35.1
|
|
17
|
+
pnpm run build
|
|
18
|
+
pnpm test
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
No configuration change is required, and no source change is required in a typical project.
|
|
22
|
+
|
|
23
|
+
**Vendor-mode projects:** §1 and §2 are single-file and independent —
|
|
24
|
+
`src/core/common/helpers/file.helper.ts` and `src/index.ts`. §3 is **not**: it adds
|
|
25
|
+
`src/core/modules/file/core-file-access-audit.initializer.ts` and changes
|
|
26
|
+
`file-roles.config.ts`, `file-roles.helper.ts` and `core.module.ts` together. Taking the initializer
|
|
27
|
+
without the `core.module.ts` provider registration gives you a file that is never constructed — a
|
|
28
|
+
security check that silently does not run, which is worse than not having it. Sync those four
|
|
29
|
+
together.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. A refused upload answers 400, not 500
|
|
34
|
+
|
|
35
|
+
11.32.4 §8 changed `multerFileFilter`'s rejection from a bare string to `new Error(...)`, intending a
|
|
36
|
+
clean 400. It never produced one.
|
|
37
|
+
|
|
38
|
+
`transformException` in `@nestjs/platform-express` returns any **non-`HttpException`** unchanged. Its
|
|
39
|
+
switch matches only multer's own message constants (`LIMIT_FILE_SIZE`, `LIMIT_UNEXPECTED_FILE`, …),
|
|
40
|
+
and a message this helper writes matches none of them — so the rejection fell off the end of the
|
|
41
|
+
switch, reached Nest's exception layer as a plain error, and the client was told the **server** had
|
|
42
|
+
failed for a file the **client** sent wrong.
|
|
43
|
+
|
|
44
|
+
Both rejection paths are affected and both are fixed: the allow-list refusal, and the
|
|
45
|
+
scriptable-type refusal (`text/html`, `image/svg+xml`) added by 11.32.4 §6.
|
|
46
|
+
|
|
47
|
+
| | Before | After |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| Status | `500 Internal Server Error` | `400 Bad Request` |
|
|
50
|
+
| Body | internal-error envelope | Nest's standard 400 shape |
|
|
51
|
+
| Message text | unchanged | unchanged — same text, still built from the accept list |
|
|
52
|
+
|
|
53
|
+
**What to check:**
|
|
54
|
+
|
|
55
|
+
- **Frontends** that branch on the status of an upload response. A rejected file was previously
|
|
56
|
+
indistinguishable from a storage outage; it now identifies itself, and the message is worth showing
|
|
57
|
+
to the user.
|
|
58
|
+
- **Tests** pinned to the broken output. A test asserting `500` on a refused upload fails and should
|
|
59
|
+
be updated to `400`.
|
|
60
|
+
- **Alerting.** Refused uploads no longer raise 5xx. If a dashboard counted them, its numbers drop —
|
|
61
|
+
that is the fix, not a regression. Conversely, an alert that was tuned to tolerate that noise can
|
|
62
|
+
be tightened again.
|
|
63
|
+
|
|
64
|
+
A consumer catching the rejection in code is unaffected: `BadRequestException extends HttpException
|
|
65
|
+
extends Error`, so an `instanceof Error` check keeps working.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 2. Seven documented helpers are now exported from the package root
|
|
70
|
+
|
|
71
|
+
The 11.34.x → 11.35.x guide's *New Exports* block lists names that `src/index.ts` never exported, so
|
|
72
|
+
importing them from `@lenne.tech/nest-server` failed to compile:
|
|
73
|
+
|
|
74
|
+
| Name | Module |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `resolveGuardRequest` | `common/helpers/execution-context-request.helper` |
|
|
77
|
+
| `buildRequestContextAwareExecute` | `common/helpers/graphql-ws-context.helper` |
|
|
78
|
+
| `buildRequestContextAwareSubscribe` | same |
|
|
79
|
+
| `createRequestContextAwareExecute` | same |
|
|
80
|
+
| `createRequestContextAwareSubscribe` | same |
|
|
81
|
+
| `getTenantContextResolver` | `common/services/core-tenant-context.registry` |
|
|
82
|
+
| `setTenantContextResolver` | same |
|
|
83
|
+
|
|
84
|
+
Purely additive — three `export * from` lines. Nothing was renamed or removed, and no existing import
|
|
85
|
+
changes meaning.
|
|
86
|
+
|
|
87
|
+
**Who this affected:** npm-mode consumers only, and only those who followed the guide. In-tree and
|
|
88
|
+
vendor-mode consumers import over relative paths and never touched the barrel, which is also why
|
|
89
|
+
nothing in this repo noticed: no framework module, test or e2e spec imports from `src/index.ts`, so a
|
|
90
|
+
helper could be written, wired, documented and shipped with the full suite green.
|
|
91
|
+
`tests/unit/public-api-barrel.spec.ts` now reads the expected names out of the migration guides
|
|
92
|
+
themselves and walks the re-export graph, so a documented-but-unexported name fails at test time
|
|
93
|
+
rather than at a consumer's `import`.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 3. The file-access warning now sees a subclassed controller
|
|
98
|
+
|
|
99
|
+
The boot warning that reports "the file gate is open and nothing declares a per-file policy" read
|
|
100
|
+
**configuration** — `file.downloadRoles` and friends. That is the correct source for a member you
|
|
101
|
+
inherit, and the wrong one for a member you re-declare.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
@Controller('files')
|
|
105
|
+
export class FileController extends CoreFileController {
|
|
106
|
+
@Get('id/:id')
|
|
107
|
+
@Roles(RoleEnum.S_EVERYONE) // anonymous downloads
|
|
108
|
+
override async getFileById(...) { return super.getFileById(...); }
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`applyFileRoles()` writes the configured roles onto `CoreFileController.prototype.getFileById`, but
|
|
113
|
+
decorator metadata lives on the **function object** — your override is a different function, it keeps
|
|
114
|
+
its own `@Roles()`, and that is the function Nest registers. The configuration never reaches the
|
|
115
|
+
route. The old warning asked `resolveRoles('downloadRoles', config)`, got `[ADMIN]`, and stayed quiet
|
|
116
|
+
while the route served everyone. Two consumer projects shipped exactly that.
|
|
117
|
+
|
|
118
|
+
`CoreFileAccessAuditInitializer` now reads the roles off the class you actually registered, at
|
|
119
|
+
`onApplicationBootstrap` (the earliest point at which the route table exists), and names the member:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
A registered file endpoint is open beyond platform admins through roles declared in your own class,
|
|
123
|
+
not through configuration (FileController.getFileById → ["s_everyone"]), and no per-file policy is
|
|
124
|
+
declared: file.access is unset and CoreFileService.checkRights() is not overridden. …
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Nothing changes about who may reach what.** Your `@Roles()` still wins; the audit reports and does
|
|
128
|
+
not correct. That is deliberate: rewriting an override's metadata would silently relax a route a
|
|
129
|
+
project pinned on purpose — which is why the TUS module's approach, where configuration wins over the
|
|
130
|
+
override, was not adopted here.
|
|
131
|
+
|
|
132
|
+
**What to do if the warning appears:**
|
|
133
|
+
|
|
134
|
+
| If the open access is | Then |
|
|
135
|
+
|---|---|
|
|
136
|
+
| intended, with a per-file rule | declare it: `file.access: 'owner' \| 'tenant' \| …`, or override `CoreFileService.checkRights()`. Either silences the warning, for both halves of the check |
|
|
137
|
+
| intended, genuinely public files | nothing is broken — but the warning is the reminder that ids are enumerable and the routes are not rate-limited |
|
|
138
|
+
| **not** intended | inherit the member instead of re-declaring it, so `file.downloadRoles` governs it again |
|
|
139
|
+
|
|
140
|
+
**No new warning if you do not subclass**, and none if you subclass without re-declaring a governed
|
|
141
|
+
member — the audit only reports roles the configuration cannot account for, so it never duplicates
|
|
142
|
+
the existing configuration-side warning.
|
|
143
|
+
|
|
144
|
+
Governed members: `getFileById`, `getFile` (controller); `getFileInfo`, `uploadFile`, `uploadFiles`,
|
|
145
|
+
`deleteFile` (resolver).
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Module Documentation
|
|
150
|
+
|
|
151
|
+
- File module, upload filters and access rules: [`src/core/modules/file/README.md`](../src/core/modules/file/README.md)
|
|
152
|
+
- Request lifecycle and exception handling: [`docs/REQUEST-LIFECYCLE.md`](../docs/REQUEST-LIFECYCLE.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.35.
|
|
3
|
+
"version": "11.35.1",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BadRequestException } from '@nestjs/common';
|
|
1
2
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
|
2
3
|
import { randomBytes } from 'crypto';
|
|
3
4
|
import { diskStorage, memoryStorage } from 'multer';
|
|
@@ -147,9 +148,14 @@ function normalizeMimeType(value: string): string {
|
|
|
147
148
|
* `options.allowScriptableTypes` is set — that is what closes the hole for
|
|
148
149
|
* expressions that already exist in consumer projects.
|
|
149
150
|
*
|
|
150
|
-
* Rejections are reported as a
|
|
151
|
-
*
|
|
152
|
-
* NestJS's `transformException`
|
|
151
|
+
* Rejections are reported as a `BadRequestException`, so the caller gets a 400
|
|
152
|
+
* with a readable reason. An `HttpException` is required, not merely tidier:
|
|
153
|
+
* NestJS's `transformException` returns any NON-`HttpException` unchanged — its
|
|
154
|
+
* switch matches only multer's own message constants (`LIMIT_FILE_SIZE`,
|
|
155
|
+
* `LIMIT_UNEXPECTED_FILE`, …), and a message written here matches none of them.
|
|
156
|
+
* A bare `new Error()` therefore still surfaced to the client as a 500, which
|
|
157
|
+
* reads as "the server broke" for what is in fact a refused file. See
|
|
158
|
+
* `tests/unit/file-upload-rejection-status.spec.ts`.
|
|
153
159
|
*/
|
|
154
160
|
export function multerFileFilter(
|
|
155
161
|
accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
|
|
@@ -163,7 +169,7 @@ export function multerFileFilter(
|
|
|
163
169
|
!options?.allowScriptableTypes &&
|
|
164
170
|
(SCRIPTABLE_UPLOAD_MIME_TYPES.includes(mimeType) || SCRIPTABLE_UPLOAD_EXTENSIONS.includes(extension))
|
|
165
171
|
) {
|
|
166
|
-
return cb(new
|
|
172
|
+
return cb(new BadRequestException(`File upload rejected: ${mimeType || 'unknown type'} may execute as script`));
|
|
167
173
|
}
|
|
168
174
|
|
|
169
175
|
const accepted =
|
|
@@ -174,7 +180,7 @@ export function multerFileFilter(
|
|
|
174
180
|
if (accepted) {
|
|
175
181
|
return cb(null, true);
|
|
176
182
|
}
|
|
177
|
-
cb(new
|
|
183
|
+
cb(new BadRequestException(`File upload only supports the following filetypes - ${describeAccept(accept)}`));
|
|
178
184
|
};
|
|
179
185
|
}
|
|
180
186
|
|
|
@@ -346,6 +346,16 @@ override async getFileById(@Param('id') id: string, @Res() res: Response) {
|
|
|
346
346
|
A class-level `@Roles()` on your subclass cannot relax an inherited member either: the inherited
|
|
347
347
|
function carries its own handler-level roles, and the two are unioned rather than overridden.
|
|
348
348
|
|
|
349
|
+
> **Boot reports this, since 11.35.1 — but it only reports.** `CoreFileAccessAuditInitializer` reads
|
|
350
|
+
> the roles off the class you actually registered and warns when a member is open beyond platform
|
|
351
|
+
> admins for a reason the configuration cannot explain. So a subclass whose `getFileById()` carries
|
|
352
|
+
> `@Roles(RoleEnum.S_EVERYONE)` is now named at startup, with the member and the offending roles.
|
|
353
|
+
>
|
|
354
|
+
> It does **not** correct anything: your `@Roles()` still wins, because overwriting it would silently
|
|
355
|
+
> relax a route you may have pinned on purpose. **If you re-declare a download member, its `@Roles()`
|
|
356
|
+
> is your whole audience gate** — the warning tells you so, it does not close it for you. Inherit the
|
|
357
|
+
> member instead if you want `file.downloadRoles` to govern it.
|
|
358
|
+
|
|
349
359
|
### If you override `getFileInfo()`: `GET /files/id/:id` no longer calls it (11.33.0)
|
|
350
360
|
|
|
351
361
|
Up to 11.32.x, `GET /files/id/:id` called the public `CoreFileService.getFileInfo()` and then let
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Injectable, Logger, OnApplicationBootstrap, Optional } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService } from '@nestjs/core';
|
|
3
|
+
|
|
4
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
5
|
+
import { CoreFileController } from './core-file.controller';
|
|
6
|
+
import { CoreFileResolver } from './core-file.resolver';
|
|
7
|
+
import { CoreFileService } from './core-file.service';
|
|
8
|
+
import {
|
|
9
|
+
FILE_ROLE_MEMBERS,
|
|
10
|
+
FileEndpointClassName,
|
|
11
|
+
ObservedFileHandler,
|
|
12
|
+
warnOnUndecidedEffectiveFileAccess,
|
|
13
|
+
} from './file-roles.config';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Audits the file endpoints a project ACTUALLY registered, and warns when one of them is open beyond
|
|
17
|
+
* platform admins for a reason the configuration cannot show.
|
|
18
|
+
*
|
|
19
|
+
* WHY THIS CANNOT LIVE WHERE THE OTHER FILE WARNINGS LIVE
|
|
20
|
+
* -------------------------------------------------------
|
|
21
|
+
* `warnOnUndecidedFileAccess()` runs in the `CoreFileService` constructor and reads CONFIGURATION.
|
|
22
|
+
* For an inherited member that is exactly right: `applyFileRoles()` writes the configured roles onto
|
|
23
|
+
* the base-class function, the subclass picks them up through the prototype chain, and configuration
|
|
24
|
+
* and reality agree.
|
|
25
|
+
*
|
|
26
|
+
* They stop agreeing when a project RE-DECLARES a member. Decorator metadata lives on the function
|
|
27
|
+
* object, so an override is a different function carrying its own `@Roles()` — and that is the
|
|
28
|
+
* function Nest registers. Answering "is the gate open?" from configuration then answers a question
|
|
29
|
+
* about a route that is not the one being served. Two consumer projects shipped anonymously readable
|
|
30
|
+
* downloads that way, and nothing said a word.
|
|
31
|
+
*
|
|
32
|
+
* Seeing the real answer needs the registered class, and the registered class only exists once Nest
|
|
33
|
+
* has built its route table. Neither the service constructor nor `CoreModule.forRoot()` — which runs
|
|
34
|
+
* before any of that — can reach it. Hence a provider, and hence `onApplicationBootstrap`.
|
|
35
|
+
*
|
|
36
|
+
* WHY DISCOVERY RATHER THAN A CONFIG OPTION
|
|
37
|
+
* ------------------------------------------
|
|
38
|
+
* The file module ships abstract classes only: there is no `CoreFileModule.forRoot()` to hand a class
|
|
39
|
+
* to, and a class reference cannot travel through `config.env.ts` (it has to survive
|
|
40
|
+
* `NEST_SERVER_CONFIG` / `NSC__*`, i.e. JSON). Asking every consumer to register their controller in
|
|
41
|
+
* a second place would also make the audit opt-in — and an opt-in audit is missing precisely where
|
|
42
|
+
* nobody thought about access control, which is the population it exists for.
|
|
43
|
+
*
|
|
44
|
+
* WARNING ONLY. It never changes a role, and deliberately so: rewriting an override's metadata would
|
|
45
|
+
* silently relax a route the project pinned on purpose, which is the trap that rules out adopting the
|
|
46
|
+
* TUS module's approach here. The audit reports; the project decides.
|
|
47
|
+
*
|
|
48
|
+
* Registered as a `CoreModule` provider; consumers never interact with it.
|
|
49
|
+
*/
|
|
50
|
+
@Injectable()
|
|
51
|
+
export class CoreFileAccessAuditInitializer implements OnApplicationBootstrap {
|
|
52
|
+
protected readonly logger = new Logger(CoreFileAccessAuditInitializer.name);
|
|
53
|
+
|
|
54
|
+
constructor(@Optional() protected readonly discoveryService?: DiscoveryService) {}
|
|
55
|
+
|
|
56
|
+
onApplicationBootstrap(): void {
|
|
57
|
+
if (!this.discoveryService) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handlers = this.collectHandlers();
|
|
62
|
+
// No file endpoint registered — there is no route to be wrong about, and the configuration-side
|
|
63
|
+
// warning in the service constructor already covers a service-only integration.
|
|
64
|
+
if (!handlers.length) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const config = ConfigService.configFastButReadOnly;
|
|
69
|
+
warnOnUndecidedEffectiveFileAccess({
|
|
70
|
+
fileConfig: config?.file,
|
|
71
|
+
handlers,
|
|
72
|
+
hasPerFileRule: this.hasPerFileRule(),
|
|
73
|
+
multiTenancyEnabled: !!config?.multiTenancy && config.multiTenancy.enabled !== false,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The effective roles of every known member on every registered file endpoint class.
|
|
79
|
+
*
|
|
80
|
+
* `proto[method]` resolves through the prototype chain, so it yields the OVERRIDE when there is one
|
|
81
|
+
* and the inherited base function otherwise — which is exactly the function Nest registered.
|
|
82
|
+
*/
|
|
83
|
+
protected collectHandlers(): ObservedFileHandler[] {
|
|
84
|
+
const handlers: ObservedFileHandler[] = [];
|
|
85
|
+
|
|
86
|
+
for (const [className, target] of this.registeredEndpointClasses()) {
|
|
87
|
+
for (const member of FILE_ROLE_MEMBERS) {
|
|
88
|
+
if (member.className !== className) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fn = (target.prototype as Record<string, unknown>)?.[member.method];
|
|
93
|
+
if (typeof fn !== 'function') {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handlers.push({
|
|
98
|
+
key: member.key,
|
|
99
|
+
member: `${target.name}.${member.method}`,
|
|
100
|
+
roles: this.effectiveRoles(fn, target),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return handlers;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The union the guards will compute for a member.
|
|
110
|
+
*
|
|
111
|
+
* Mirrors `mergeRolesMetadata([handlerRoles, classRoles])`: both halves count, so a class-level
|
|
112
|
+
* `@Roles()` on the subclass is seen too. `Reflect.getMetadata` walks the prototype chain for the
|
|
113
|
+
* class, which is what makes an undecorated subclass inherit the core `@Roles(ADMIN)` — the same
|
|
114
|
+
* resolution Nest's `Reflector` performs.
|
|
115
|
+
*/
|
|
116
|
+
protected effectiveRoles(fn: unknown, target: Function): string[] {
|
|
117
|
+
const handlerRoles: string[] = Reflect.getMetadata('roles', fn as object) ?? [];
|
|
118
|
+
const classRoles: string[] = Reflect.getMetadata('roles', target) ?? [];
|
|
119
|
+
return [...handlerRoles, ...classRoles];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether the registered `CoreFileService` overrides `checkRights()` — the project wrote a rule. */
|
|
123
|
+
protected hasPerFileRule(): boolean {
|
|
124
|
+
const services = (this.discoveryService?.getProviders() ?? [])
|
|
125
|
+
.map((wrapper) => wrapper.instance)
|
|
126
|
+
.filter((instance): instance is CoreFileService => instance instanceof CoreFileService);
|
|
127
|
+
|
|
128
|
+
return services.some((service) => (service as any).checkRights !== (CoreFileService.prototype as any).checkRights);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Every registered class that IS one of the two file endpoint classes, deduplicated.
|
|
133
|
+
*
|
|
134
|
+
* Read off the instance rather than the wrapper's `metatype`, because that is the object whose
|
|
135
|
+
* prototype chain answers the `instanceof` question reliably for both controllers and providers.
|
|
136
|
+
*/
|
|
137
|
+
protected registeredEndpointClasses(): [FileEndpointClassName, Function][] {
|
|
138
|
+
const found = new Map<Function, FileEndpointClassName>();
|
|
139
|
+
|
|
140
|
+
const consider = (instance: unknown): void => {
|
|
141
|
+
if (!instance || typeof instance !== 'object') {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const target = (instance as object).constructor;
|
|
145
|
+
if (typeof target !== 'function' || found.has(target)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (instance instanceof CoreFileController) {
|
|
149
|
+
found.set(target, 'CoreFileController');
|
|
150
|
+
} else if (instance instanceof CoreFileResolver) {
|
|
151
|
+
found.set(target, 'CoreFileResolver');
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
for (const wrapper of this.discoveryService?.getControllers() ?? []) {
|
|
156
|
+
consider(wrapper.instance);
|
|
157
|
+
}
|
|
158
|
+
for (const wrapper of this.discoveryService?.getProviders() ?? []) {
|
|
159
|
+
consider(wrapper.instance);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...found].map(([target, className]) => [className, target]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -36,6 +36,31 @@ export const FILE_ROLE_DEFAULTS: Record<FileRoleKey, string[]> = {
|
|
|
36
36
|
uploadRoles: [RoleEnum.ADMIN],
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Which endpoint member each knob governs, by NAME only.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately strings rather than function references: this file must stay a leaf that imports
|
|
43
|
+
* nothing but enums and interfaces (see the header), and both consumers need the same list —
|
|
44
|
+
* `applyFileRoles()` writes metadata onto these members, and the boot audit reads it back off
|
|
45
|
+
* whichever class the project actually registered. Two hand-maintained copies of that list is
|
|
46
|
+
* exactly how one of them ends up governing a member the other forgot.
|
|
47
|
+
*
|
|
48
|
+
* `getFileInfo` rides with `downloadRoles` rather than getting its own knob: it answers filename,
|
|
49
|
+
* size and content type for a blob, which is the metadata half of a download. Splitting it would let
|
|
50
|
+
* a project accidentally publish the bucket's contents list while believing downloads were closed.
|
|
51
|
+
*/
|
|
52
|
+
export const FILE_ROLE_MEMBERS: { className: FileEndpointClassName; key: FileRoleKey; method: string }[] = [
|
|
53
|
+
{ className: 'CoreFileController', key: 'downloadRoles', method: 'getFileById' },
|
|
54
|
+
{ className: 'CoreFileController', key: 'downloadRoles', method: 'getFile' },
|
|
55
|
+
{ className: 'CoreFileResolver', key: 'downloadRoles', method: 'getFileInfo' },
|
|
56
|
+
{ className: 'CoreFileResolver', key: 'uploadRoles', method: 'uploadFile' },
|
|
57
|
+
{ className: 'CoreFileResolver', key: 'uploadRoles', method: 'uploadFiles' },
|
|
58
|
+
{ className: 'CoreFileResolver', key: 'deleteRoles', method: 'deleteFile' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** The two core endpoint classes the role knobs govern. */
|
|
62
|
+
export type FileEndpointClassName = 'CoreFileController' | 'CoreFileResolver';
|
|
63
|
+
|
|
39
64
|
/**
|
|
40
65
|
* Resolve one knob to the role list that will actually be applied.
|
|
41
66
|
*
|
|
@@ -62,6 +87,21 @@ export function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
|
|
|
62
87
|
return configured;
|
|
63
88
|
}
|
|
64
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Did somebody DECLARE the per-file policy?
|
|
92
|
+
*
|
|
93
|
+
* Shared by both file-access warnings on purpose. They must silence on exactly the same conditions,
|
|
94
|
+
* and two copies of that rule is how a third silencer gets added to one and forgotten in the other —
|
|
95
|
+
* at which point the boot audit starts firing on a project that did decide, gets muted, and protects
|
|
96
|
+
* nobody. Keeping it in one place makes that particular drift impossible rather than merely unlikely.
|
|
97
|
+
*
|
|
98
|
+
* `'custom'` does not count: it is the escape hatch that says "I will answer this in code", so the
|
|
99
|
+
* only evidence that somebody actually did is an overridden `checkRights()`.
|
|
100
|
+
*/
|
|
101
|
+
export function hasDeclaredFilePolicy(options: { fileConfig?: IFileConfig; hasPerFileRule: boolean }): boolean {
|
|
102
|
+
return options.hasPerFileRule || !!(options.fileConfig?.access && options.fileConfig.access !== 'custom');
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
/**
|
|
66
106
|
* Warn when presigned S3 downloads are combined with a restricted `downloadRoles`.
|
|
67
107
|
*
|
|
@@ -152,6 +192,15 @@ export function warnOnPresignedDownloadsWithRestrictedRoles(
|
|
|
152
192
|
* public logos, and refusing to start on a configuration that is correct for the second would be
|
|
153
193
|
* wrong. What it can do is refuse to be silent.
|
|
154
194
|
*
|
|
195
|
+
* SCOPE — this function reads CONFIGURATION, which is the right source for a member the project
|
|
196
|
+
* INHERITS: `applyFileRoles()` writes the configured roles onto the base-class function, the subclass
|
|
197
|
+
* picks them up through the prototype chain, and config and reality agree. It says nothing about a
|
|
198
|
+
* member the project RE-DECLARES, because an override is a different function carrying its own
|
|
199
|
+
* `@Roles()` and the configuration never reaches that route. That half is covered by
|
|
200
|
+
* {@link warnOnUndecidedEffectiveFileAccess}, driven from `CoreFileAccessAuditInitializer` at
|
|
201
|
+
* bootstrap — the earliest point at which the registered class exists. The two do not overlap: the
|
|
202
|
+
* audit reports only roles this function's source cannot account for.
|
|
203
|
+
*
|
|
155
204
|
* @param hasPerFileRule whether `CoreFileService.checkRights()` is overridden — the caller knows,
|
|
156
205
|
* because it has the instance; this helper stays a pure function so it can be unit-tested.
|
|
157
206
|
* @returns the message, or `undefined` when there is nothing to warn about. Returned as well as
|
|
@@ -166,7 +215,7 @@ export function warnOnUndecidedFileAccess(options: {
|
|
|
166
215
|
const { fileConfig, hasPerFileRule, multiTenancyEnabled } = options;
|
|
167
216
|
|
|
168
217
|
// (1) and (2): somebody decided.
|
|
169
|
-
if (
|
|
218
|
+
if (hasDeclaredFilePolicy({ fileConfig, hasPerFileRule })) {
|
|
170
219
|
return undefined;
|
|
171
220
|
}
|
|
172
221
|
|
|
@@ -200,3 +249,109 @@ export function warnOnUndecidedFileAccess(options: {
|
|
|
200
249
|
logger.warn(message);
|
|
201
250
|
return message;
|
|
202
251
|
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One registered endpoint member, as the GUARDS will see it.
|
|
255
|
+
*
|
|
256
|
+
* `roles` is the union of handler-level and class-level metadata, because that is what
|
|
257
|
+
* `mergeRolesMetadata` computes — not the handler alone. A subclass that carries a class-level
|
|
258
|
+
* `@Roles(S_EVERYONE)` widens every member it declares, and reading only the handler would miss it.
|
|
259
|
+
*/
|
|
260
|
+
export interface ObservedFileHandler {
|
|
261
|
+
/** which knob governs this member */
|
|
262
|
+
key: FileRoleKey;
|
|
263
|
+
/** `'FileController.getFileById'` — named as REGISTERED, so the operator can go straight to it */
|
|
264
|
+
member: string;
|
|
265
|
+
/** the effective role union the guards will evaluate */
|
|
266
|
+
roles: string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Warn when a REGISTERED file endpoint is open beyond platform admins for a reason the configuration
|
|
271
|
+
* does not explain — i.e. a `@Roles()` written in the project's own subclass.
|
|
272
|
+
*
|
|
273
|
+
* WHY THIS EXISTS SEPARATELY FROM {@link warnOnUndecidedFileAccess}. That one reads CONFIGURATION,
|
|
274
|
+
* which is the right source for the inherited case: `applyFileRoles()` writes the configured roles
|
|
275
|
+
* onto the base-class member, an inheriting subclass picks them up through the prototype chain, and
|
|
276
|
+
* config and reality agree. They stop agreeing the moment a project RE-DECLARES a member. Decorator
|
|
277
|
+
* metadata lives on the function object, so an override is a different function carrying its own
|
|
278
|
+
* roles — and that is the function Nest registers. The configuration never reaches the route.
|
|
279
|
+
*
|
|
280
|
+
* The consequence was a silence exactly where the noise was wanted: a subclassed `getFileById()` with
|
|
281
|
+
* `@Roles(RoleEnum.S_EVERYONE)` serves anonymous downloads while `resolveRoles('downloadRoles', …)`
|
|
282
|
+
* still answers `[ADMIN]`. Two independent consumer projects shipped that, and in both the actually
|
|
283
|
+
* open routes were the ones nothing reported. A warning that is quiet in the dangerous case and loud
|
|
284
|
+
* in the safe one is worse than no warning, because it reads as a clean bill of health.
|
|
285
|
+
*
|
|
286
|
+
* WHAT IT REPORTS — only roles the configuration does not account for:
|
|
287
|
+
*
|
|
288
|
+
* effective = union(handler roles, class roles) // what mergeRolesMetadata gives the guard
|
|
289
|
+
* unexplained = effective − {ADMIN} − configured(key)
|
|
290
|
+
*
|
|
291
|
+
* That subtraction is what keeps this from double-warning. When the widening came from
|
|
292
|
+
* `file.downloadRoles`, {@link warnOnUndecidedFileAccess} has already said so and `unexplained` is
|
|
293
|
+
* empty. When an override widens FURTHER than the configuration does, only the extra roles are
|
|
294
|
+
* named — the part that is genuinely invisible elsewhere.
|
|
295
|
+
*
|
|
296
|
+
* ADMIN is subtracted because both endpoint classes carry a class-level `@Roles(ADMIN)` that the
|
|
297
|
+
* guards union in unconditionally. It is present on every member and never widens anything.
|
|
298
|
+
*
|
|
299
|
+
* The silencers are deliberately identical to {@link warnOnUndecidedFileAccess} — an overridden
|
|
300
|
+
* `checkRights()` or a declared `file.access` means somebody decided, and how they decided is beyond
|
|
301
|
+
* what a boot check can grade. A warning that fires on a correct setup gets muted, and a muted
|
|
302
|
+
* warning protects nobody.
|
|
303
|
+
*
|
|
304
|
+
* @returns the message, or `undefined` when there is nothing to report. Returned as well as logged
|
|
305
|
+
* for the same reason as the other two warnings: the message IS the contract, and a module-private
|
|
306
|
+
* Logger cannot be asserted against from a unit test.
|
|
307
|
+
*/
|
|
308
|
+
export function warnOnUndecidedEffectiveFileAccess(options: {
|
|
309
|
+
fileConfig?: IFileConfig;
|
|
310
|
+
handlers: ObservedFileHandler[];
|
|
311
|
+
hasPerFileRule: boolean;
|
|
312
|
+
multiTenancyEnabled: boolean;
|
|
313
|
+
}): string | undefined {
|
|
314
|
+
const { fileConfig, handlers, hasPerFileRule, multiTenancyEnabled } = options;
|
|
315
|
+
|
|
316
|
+
// Somebody decided. The SAME predicate the configuration-side warning uses — see
|
|
317
|
+
// hasDeclaredFilePolicy() for why this must not be a second copy of the rule.
|
|
318
|
+
if (hasDeclaredFilePolicy({ fileConfig, hasPerFileRule })) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const findings: string[] = [];
|
|
323
|
+
for (const handler of handlers) {
|
|
324
|
+
const configured = resolveRoles(handler.key, fileConfig);
|
|
325
|
+
const unexplained = handler.roles.filter((role) => role !== RoleEnum.ADMIN && !configured.includes(role));
|
|
326
|
+
if (unexplained.length) {
|
|
327
|
+
findings.push(`${handler.member} → ${JSON.stringify([...new Set(unexplained)])}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!findings.length) {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const tenantNote = multiTenancyEnabled
|
|
336
|
+
? ' multiTenancy is active, and the leak crosses tenants too: the file stores are reached outside ' +
|
|
337
|
+
'Mongoose, so mongooseTenantPlugin never scopes them and these role names resolve against ' +
|
|
338
|
+
'user.roles — a GLOBAL attribute — never against membership.role.'
|
|
339
|
+
: '';
|
|
340
|
+
|
|
341
|
+
const message =
|
|
342
|
+
`A registered file endpoint is open beyond platform admins through roles declared in your own ` +
|
|
343
|
+
`class, not through configuration (${findings.join(', ')}), and no per-file policy is declared: ` +
|
|
344
|
+
`file.access is unset and CoreFileService.checkRights() is not overridden. Because the member is ` +
|
|
345
|
+
`RE-DECLARED, file.downloadRoles/uploadRoles/deleteRoles do NOT apply to it — decorator metadata ` +
|
|
346
|
+
`lives on the function object, so your override keeps its own @Roles() and the configuration ` +
|
|
347
|
+
`never reaches the route. Every holder of such a role can therefore read, overwrite or delete ` +
|
|
348
|
+
`EVERY file — and file ids are not secret, they are ENUMERABLE (an ObjectId shares a per-process ` +
|
|
349
|
+
`random part and an incrementing counter, so one own upload reveals the neighbourhood), with no ` +
|
|
350
|
+
`rate limit on the file routes.${tenantNote} Either inherit the member instead of re-declaring ` +
|
|
351
|
+
`it, so the knobs apply, or declare the per-file policy with file.access ` +
|
|
352
|
+
`('public' | 'authenticated' | 'owner' | 'tenant') or an overridden checkRights() — see ` +
|
|
353
|
+
`src/core/modules/file/README.md § Access control.`;
|
|
354
|
+
|
|
355
|
+
logger.warn(message);
|
|
356
|
+
return message;
|
|
357
|
+
}
|