@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
|
@@ -1,8 +1,48 @@
|
|
|
1
|
-
import { ForbiddenException } from '@nestjs/common';
|
|
1
|
+
import { ForbiddenException, Logger } from '@nestjs/common';
|
|
2
2
|
|
|
3
3
|
import { ConfigService } from '../services/config.service';
|
|
4
4
|
import { RequestContext } from '../services/request-context.service';
|
|
5
5
|
|
|
6
|
+
/** Models already warned about, so the message appears once per process rather than per query. */
|
|
7
|
+
const isolationDisabledWarned = new Set<string>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Say out loud that isolation is off for a schema that was built for it.
|
|
11
|
+
*
|
|
12
|
+
* `excludeSchemas` is a legitimate feature — a genuinely global lookup table has no tenant. But this
|
|
13
|
+
* plugin only ever attaches to schemas that DECLARE a `tenantId` field, so reaching here means the
|
|
14
|
+
* author of that schema intended per-tenant rows and the configuration silently overrides them.
|
|
15
|
+
* Nothing said so, and the shape is easy to arrive at by accident: the framework's own documentation
|
|
16
|
+
* carried `excludeSchemas: ['User', 'Session']` in a copyable `@example` from 11.20.0 onwards, which
|
|
17
|
+
* on a project with per-tenant users switches filtering off for the user collection.
|
|
18
|
+
*
|
|
19
|
+
* A warning, not a boot failure. An ambiguous ROLE vocabulary fails the boot because its access
|
|
20
|
+
* decisions cannot be resolved coherently; this configuration is perfectly coherent — it is simply
|
|
21
|
+
* one nobody may make without noticing.
|
|
22
|
+
*
|
|
23
|
+
* The membership model is skipped: `CoreModule` adds it to `excludeSchemas` itself because
|
|
24
|
+
* membership is tenant-spanning by design, so warning about it would be the framework complaining
|
|
25
|
+
* about its own correct default.
|
|
26
|
+
*/
|
|
27
|
+
function warnIsolationDisabled(modelName: string): void {
|
|
28
|
+
if (isolationDisabledWarned.has(modelName)) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
isolationDisabledWarned.add(modelName);
|
|
32
|
+
|
|
33
|
+
const membershipModel = ConfigService.configFastButReadOnly?.multiTenancy?.membershipModel ?? 'TenantMember';
|
|
34
|
+
if (modelName === membershipModel) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
new Logger('mongooseTenantPlugin').warn(
|
|
39
|
+
`Tenant isolation is DISABLED for "${modelName}": the schema declares a tenantId field, but ` +
|
|
40
|
+
`"${modelName}" is listed in multiTenancy.excludeSchemas — so no tenant filter is applied and ` +
|
|
41
|
+
`every query on it sees every tenant's rows. Remove it from excludeSchemas unless this ` +
|
|
42
|
+
`collection is genuinely global.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
6
46
|
/**
|
|
7
47
|
* Mongoose plugin that provides automatic tenant-based data isolation.
|
|
8
48
|
* Only activates on schemas that have a `tenantId` path defined.
|
|
@@ -63,6 +103,19 @@ export function mongooseTenantPlugin(schema) {
|
|
|
63
103
|
if (filter !== undefined) {
|
|
64
104
|
this.where(filter);
|
|
65
105
|
}
|
|
106
|
+
|
|
107
|
+
// The filter above constrains WHICH rows are touched, never what the update WRITES. So a
|
|
108
|
+
// caller could legitimately match their own row and then rewrite its tenantId — moving it
|
|
109
|
+
// into a tenant they control, or simply out of reach of its rightful owner.
|
|
110
|
+
const single = resolveSingleTenantId(modelName);
|
|
111
|
+
if (single) {
|
|
112
|
+
const update: any = typeof this.getUpdate === 'function' ? this.getUpdate() : undefined;
|
|
113
|
+
for (const candidate of [update?.tenantId, update?.$set?.tenantId, update?.$setOnInsert?.tenantId]) {
|
|
114
|
+
if (candidate !== undefined) {
|
|
115
|
+
assertOwnTenant(candidate, single, false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
66
119
|
});
|
|
67
120
|
}
|
|
68
121
|
|
|
@@ -70,14 +123,23 @@ export function mongooseTenantPlugin(schema) {
|
|
|
70
123
|
// Intentional asymmetry: writes only set tenantId when truthy (not null).
|
|
71
124
|
// Only uses single tenantId from header — tenantIds array is for reads only.
|
|
72
125
|
schema.pre('save', function () {
|
|
126
|
+
// Document hooks: `this` is the document instance — modelName is on the constructor (the Model class)
|
|
127
|
+
const modelName = (this.constructor as any).modelName;
|
|
128
|
+
const tenantId = resolveSingleTenantId(modelName);
|
|
129
|
+
if (!tenantId) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
73
133
|
if (this.isNew && !this['tenantId']) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const tenantId = resolveSingleTenantId(modelName);
|
|
77
|
-
if (tenantId) {
|
|
78
|
-
this['tenantId'] = tenantId;
|
|
79
|
-
}
|
|
134
|
+
this['tenantId'] = tenantId;
|
|
135
|
+
return;
|
|
80
136
|
}
|
|
137
|
+
|
|
138
|
+
// A tenantId that is already on the document did NOT come from this hook. Stamping only when
|
|
139
|
+
// absent is right for system writes, but on its own it means a caller-supplied value survives —
|
|
140
|
+
// so an explicit foreign tenantId would place the row in someone else's tenant, and modifying it
|
|
141
|
+
// on an existing document would move the row out of this one.
|
|
142
|
+
assertOwnTenant(this['tenantId'], tenantId, this.isNew);
|
|
81
143
|
});
|
|
82
144
|
|
|
83
145
|
// === insertMany (Mongoose 9: first arg is docs array, no next callback) ===
|
|
@@ -89,6 +151,8 @@ export function mongooseTenantPlugin(schema) {
|
|
|
89
151
|
for (const doc of docs) {
|
|
90
152
|
if (!doc.tenantId) {
|
|
91
153
|
doc.tenantId = tenantId;
|
|
154
|
+
} else {
|
|
155
|
+
assertOwnTenant(doc.tenantId, tenantId, true);
|
|
92
156
|
}
|
|
93
157
|
}
|
|
94
158
|
}
|
|
@@ -123,17 +187,182 @@ export function mongooseTenantPlugin(schema) {
|
|
|
123
187
|
}
|
|
124
188
|
});
|
|
125
189
|
|
|
126
|
-
// ===
|
|
190
|
+
// === estimatedDocumentCount: unfilterable, so refuse it inside a tenant context ===
|
|
191
|
+
//
|
|
192
|
+
// It reads collection metadata rather than running a query, so MongoDB ignores any filter — there
|
|
193
|
+
// is nothing to inject. Returning it unchanged would hand a tenant the row count of the WHOLE
|
|
194
|
+
// collection, i.e. every other tenant's volume. Throwing is the only honest option; callers that
|
|
195
|
+
// want a tenant's own count use countDocuments(), which IS filtered.
|
|
196
|
+
schema.pre('estimatedDocumentCount', function () {
|
|
197
|
+
const modelName = (this as any).model?.modelName;
|
|
198
|
+
if (shouldBypass(modelName)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const context = RequestContext.get();
|
|
202
|
+
if (context?.isAdminBypass && !context?.tenantId) {
|
|
203
|
+
return; // platform admin without a tenant header legitimately sees everything
|
|
204
|
+
}
|
|
205
|
+
throw new ForbiddenException(
|
|
206
|
+
"estimatedDocumentCount() cannot be tenant-filtered and would expose other tenants' row counts — " +
|
|
207
|
+
'use countDocuments() instead',
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// === Aggregate: prepend $match stage, and secure every cross-collection stage ===
|
|
127
212
|
schema.pre('aggregate', function () {
|
|
128
213
|
// Aggregate hooks: `this` is the Aggregation pipeline — the model is on the internal `_model` property
|
|
129
|
-
const
|
|
214
|
+
const model = (this as any)._model;
|
|
215
|
+
const modelName = model?.modelName;
|
|
130
216
|
const filter = resolveTenantFilter(modelName);
|
|
131
|
-
if (filter
|
|
132
|
-
|
|
217
|
+
if (filter === undefined) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const pipeline = this.pipeline();
|
|
222
|
+
|
|
223
|
+
// $out and $merge are the only aggregation stages that WRITE, and neither can be constrained
|
|
224
|
+
// the way a read stage can: $out REPLACES a whole collection, and $merge writes rows whose
|
|
225
|
+
// tenantId comes from the pipeline rather than from the caller's context. Either one turns an
|
|
226
|
+
// aggregation into a way to launder rows across the boundary — or, with $out, to erase another
|
|
227
|
+
// tenant's collection outright. There is nothing to inject here, so a tenant-scoped caller is
|
|
228
|
+
// refused; system code that legitimately needs them runs under runWithBypassTenantGuard().
|
|
229
|
+
const writeStage = pipeline.find((stage: any) => stage && (stage.$out !== undefined || stage.$merge !== undefined));
|
|
230
|
+
if (writeStage) {
|
|
231
|
+
throw new ForbiddenException(
|
|
232
|
+
`Aggregation write stages ($out / $merge) are not permitted inside a tenant context — ` +
|
|
233
|
+
'they cannot be tenant-filtered. Run them as a system operation if this is intended.',
|
|
234
|
+
);
|
|
133
235
|
}
|
|
236
|
+
|
|
237
|
+
pipeline.unshift({ $match: filter });
|
|
238
|
+
|
|
239
|
+
// The $match above only constrains the SOURCE collection. `$lookup`, `$unionWith` and
|
|
240
|
+
// `$graphLookup` read a DIFFERENT collection, and that collection's own `aggregate` hook never
|
|
241
|
+
// fires — the join runs inside this pipeline. Without the pass below, a single aggregation
|
|
242
|
+
// returns every tenant's rows from the joined collection, which is exactly the shape a
|
|
243
|
+
// reporting query takes.
|
|
244
|
+
secureCrossCollectionStages(pipeline, model);
|
|
134
245
|
});
|
|
135
246
|
}
|
|
136
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Inject the tenant filter into every stage that reads another collection.
|
|
250
|
+
*
|
|
251
|
+
* Recurses, because these stages nest: a `$lookup.pipeline` may itself contain a `$lookup`, and
|
|
252
|
+
* `$facet` holds a sub-pipeline per key.
|
|
253
|
+
*
|
|
254
|
+
* A joined collection is only constrained when it is itself tenant-scoped (its model has a
|
|
255
|
+
* `tenantId` path and is not excluded). Joining a global lookup table stays untouched — filtering
|
|
256
|
+
* it by `tenantId` would silently return nothing.
|
|
257
|
+
*/
|
|
258
|
+
function secureCrossCollectionStages(pipeline: any[], model: any): void {
|
|
259
|
+
if (!Array.isArray(pipeline)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const stage of pipeline) {
|
|
264
|
+
if (!stage || typeof stage !== 'object') {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (stage.$lookup) {
|
|
269
|
+
const filter = filterForCollection(stage.$lookup.from, model);
|
|
270
|
+
if (filter) {
|
|
271
|
+
// MongoDB 5.0+ allows `pipeline` alongside localField/foreignField, so this works for the
|
|
272
|
+
// concise join form too, not only the explicit-pipeline one.
|
|
273
|
+
stage.$lookup.pipeline = [{ $match: filter }, ...(stage.$lookup.pipeline ?? [])];
|
|
274
|
+
}
|
|
275
|
+
secureCrossCollectionStages(stage.$lookup.pipeline, model);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (stage.$unionWith) {
|
|
279
|
+
// Two forms: `$unionWith: 'coll'` and `$unionWith: { coll, pipeline }`.
|
|
280
|
+
if (typeof stage.$unionWith === 'string') {
|
|
281
|
+
const filter = filterForCollection(stage.$unionWith, model);
|
|
282
|
+
if (filter) {
|
|
283
|
+
stage.$unionWith = { coll: stage.$unionWith, pipeline: [{ $match: filter }] };
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
const filter = filterForCollection(stage.$unionWith.coll, model);
|
|
287
|
+
if (filter) {
|
|
288
|
+
stage.$unionWith.pipeline = [{ $match: filter }, ...(stage.$unionWith.pipeline ?? [])];
|
|
289
|
+
}
|
|
290
|
+
secureCrossCollectionStages(stage.$unionWith.pipeline, model);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (stage.$graphLookup) {
|
|
295
|
+
const filter = filterForCollection(stage.$graphLookup.from, model);
|
|
296
|
+
if (filter) {
|
|
297
|
+
// $graphLookup takes no pipeline; `restrictSearchWithMatch` is its filter hook and applies
|
|
298
|
+
// to EVERY recursive step, which is what a traversal needs.
|
|
299
|
+
stage.$graphLookup.restrictSearchWithMatch = {
|
|
300
|
+
...stage.$graphLookup.restrictSearchWithMatch,
|
|
301
|
+
...filter,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (stage.$facet && typeof stage.$facet === 'object') {
|
|
307
|
+
for (const branch of Object.values(stage.$facet)) {
|
|
308
|
+
secureCrossCollectionStages(branch as any[], model);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Tenant filter for a JOINED collection, or `undefined` when none applies.
|
|
316
|
+
*
|
|
317
|
+
* Resolves the collection name back to its model so the same "is this tenant-scoped?" rule applies
|
|
318
|
+
* as for a direct query. An unknown collection name yields no filter: the plugin cannot tell
|
|
319
|
+
* whether it is tenant-scoped, and inventing a `tenantId` constraint for a collection that has no
|
|
320
|
+
* such field would turn a working join into an empty result.
|
|
321
|
+
*/
|
|
322
|
+
function filterForCollection(collectionName: unknown, sourceModel: any): Record<string, any> | undefined {
|
|
323
|
+
if (typeof collectionName !== 'string' || !collectionName) {
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const models = sourceModel?.db?.models ?? {};
|
|
328
|
+
for (const name of Object.keys(models)) {
|
|
329
|
+
const candidate = models[name];
|
|
330
|
+
if (candidate?.collection?.name !== collectionName) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (!candidate.schema?.path('tenantId')) {
|
|
334
|
+
return undefined; // not tenant-scoped — leave the join alone
|
|
335
|
+
}
|
|
336
|
+
const filter = resolveTenantFilter(name);
|
|
337
|
+
// `{}` means "admin bypass, sees everything" — nothing to inject.
|
|
338
|
+
return filter && Object.keys(filter).length ? filter : undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Refuse a tenantId that is not the caller's own.
|
|
346
|
+
*
|
|
347
|
+
* Covers both directions of the same boundary:
|
|
348
|
+
* - creating a row with a FOREIGN tenantId → planting data in someone else's tenant;
|
|
349
|
+
* - changing an existing row's tenantId → moving data out of this one.
|
|
350
|
+
*
|
|
351
|
+
* Throwing rather than overwriting is deliberate. Silently rewriting the value would make a request
|
|
352
|
+
* that asked for something impossible look like it succeeded, and for tenant-scoped medical data
|
|
353
|
+
* "the write went somewhere other than you asked" is not a recoverable ambiguity.
|
|
354
|
+
*/
|
|
355
|
+
function assertOwnTenant(value: unknown, ownTenantId: string, isNew: boolean): void {
|
|
356
|
+
if (value === undefined || value === null || value === ownTenantId) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
throw new ForbiddenException(
|
|
360
|
+
isNew
|
|
361
|
+
? `Cannot create a document in a foreign tenant (got "${String(value)}", own tenant is "${ownTenantId}")`
|
|
362
|
+
: `Cannot move a document to a foreign tenant (got "${String(value)}", own tenant is "${ownTenantId}")`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
137
366
|
/**
|
|
138
367
|
* Check common bypass conditions.
|
|
139
368
|
*
|
|
@@ -146,7 +375,10 @@ function shouldBypass(modelName?: string): boolean {
|
|
|
146
375
|
const context = RequestContext.get();
|
|
147
376
|
if (!context) return true;
|
|
148
377
|
if (context.bypassTenantGuard) return true;
|
|
149
|
-
if (modelName && mtConfig.excludeSchemas?.includes(modelName))
|
|
378
|
+
if (modelName && mtConfig.excludeSchemas?.includes(modelName)) {
|
|
379
|
+
warnIsolationDisabled(modelName);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
150
382
|
|
|
151
383
|
return false;
|
|
152
384
|
}
|
|
@@ -92,7 +92,15 @@ export class CoreS3Service implements OnApplicationShutdown, OnModuleInit {
|
|
|
92
92
|
this.logger.warn('Ignoring the `s3` configuration: no `bucket` is set, so S3 cannot be used.');
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// `enabled: false` disables, exactly as everywhere else in the config (see
|
|
96
|
+
// .claude/rules/configurable-features.md). Reading any object as "enabled" made this the one
|
|
97
|
+
// knob where the documented pre-configuration idiom silently turned a feature ON — and it is a
|
|
98
|
+
// feature that hands out session-less bearer URLs.
|
|
99
|
+
const rawPresigned = raw.presignedDownloads;
|
|
100
|
+
const presigned =
|
|
101
|
+
rawPresigned && typeof rawPresigned === 'object' && (rawPresigned as { enabled?: boolean }).enabled === false
|
|
102
|
+
? undefined
|
|
103
|
+
: rawPresigned;
|
|
96
104
|
this.config = {
|
|
97
105
|
accessKeyId: raw.accessKeyId,
|
|
98
106
|
autoCreateBucket: raw.autoCreateBucket ?? false,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a non-HTTP transport asks the tenant machinery "which tenant is this caller in?".
|
|
3
|
+
*
|
|
4
|
+
* DELIBERATELY IMPORT-FREE apart from a type-only import, so it stays a leaf — and it lives under
|
|
5
|
+
* `src/core/common/` rather than next to the tenant module for a second reason: the reader is
|
|
6
|
+
* `graphql-ws-context.helper`, also in `src/core/common/`, and `src/core/common/**` must not import
|
|
7
|
+
* from `src/core/modules/**`. The writer (`CoreTenantGuard`) imports in the allowed direction.
|
|
8
|
+
* A registry rather than DI because the GraphQL wiring in `core.module.ts` must not depend on a
|
|
9
|
+
* provider that only exists when multi-tenancy is configured. Same pattern as
|
|
10
|
+
* `core-cron-jobs.registry.ts`. See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
|
|
11
|
+
*
|
|
12
|
+
* WHY IT EXISTS: `CoreTenantGuard` answers this question from an Express request and writes the
|
|
13
|
+
* answer onto it (`request.tenantId` / `request.tenantIds`), which `RequestContextMiddleware` then
|
|
14
|
+
* exposes through AsyncLocalStorage. A WebSocket never traverses either — no Express middleware runs
|
|
15
|
+
* on an upgrade, and the guard's own `getRequest()` returns nothing for a subscription context, so it
|
|
16
|
+
* returns `true` without deciding anything. This registry is what lets the WebSocket path ask the
|
|
17
|
+
* same question through the same membership logic and cache.
|
|
18
|
+
*/
|
|
19
|
+
import type { IRequestContext } from './request-context.service';
|
|
20
|
+
|
|
21
|
+
/** The tenant-relevant slice of a request context — everything the Mongoose tenant plugin reads. */
|
|
22
|
+
export type ResolvedTenantContext = Pick<IRequestContext, 'isAdminBypass' | 'tenantId' | 'tenantIds' | 'tenantRole'>;
|
|
23
|
+
|
|
24
|
+
export interface TenantContextResolver {
|
|
25
|
+
/**
|
|
26
|
+
* @param user the authenticated user (with `roles`), or undefined for an anonymous caller
|
|
27
|
+
* @param headerTenantId the raw tenant id the transport carried, if any — NEVER trusted; it is
|
|
28
|
+
* validated against an active membership before it is returned
|
|
29
|
+
*/
|
|
30
|
+
resolve(user: { id: string; roles?: string[] } | undefined, headerTenantId?: string): Promise<ResolvedTenantContext>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let resolver: TenantContextResolver | undefined;
|
|
34
|
+
|
|
35
|
+
/** Registered by `CoreTenantGuard` on init. Idempotent — test fixtures build the guard repeatedly. */
|
|
36
|
+
export function setTenantContextResolver(next: TenantContextResolver | undefined): void {
|
|
37
|
+
resolver = next;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The registered resolver, or `undefined` when multi-tenancy is not in play.
|
|
42
|
+
*
|
|
43
|
+
* `undefined` must NOT be read as "no tenant restrictions": it means nothing can answer the
|
|
44
|
+
* question, so a caller establishes a context WITHOUT tenant information and lets the Mongoose
|
|
45
|
+
* plugin's safety net refuse tenant-scoped reads. That is the difference between "this data is not
|
|
46
|
+
* tenant-scoped" and "nobody knows which tenant this is".
|
|
47
|
+
*/
|
|
48
|
+
export function getTenantContextResolver(): TenantContextResolver | undefined {
|
|
49
|
+
return resolver;
|
|
50
|
+
}
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
} from '@nestjs/common';
|
|
10
10
|
import { ModuleRef, Reflector } from '@nestjs/core';
|
|
11
11
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
12
|
+
|
|
13
|
+
import { resolveGuardRequest } from '../../../common/helpers/execution-context-request.helper';
|
|
12
14
|
import { firstValueFrom, isObservable } from 'rxjs';
|
|
13
15
|
|
|
14
16
|
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
@@ -341,10 +343,10 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
341
343
|
* Integrate request from GraphQL
|
|
342
344
|
*/
|
|
343
345
|
getRequest(context: ExecutionContext) {
|
|
344
|
-
|
|
345
|
-
// For GraphQL
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
return ctx
|
|
346
|
+
// Shared with BetterAuthRolesGuard and CoreTenantGuard — see execution-context-request.helper.
|
|
347
|
+
// For GraphQL over HTTP the context carries `.req`; for REST it does not (it is the `next`
|
|
348
|
+
// function) and the HTTP fallback applies; for a GraphQL SUBSCRIPTION neither holds, and the
|
|
349
|
+
// subscription context itself is the request-like object.
|
|
350
|
+
return resolveGuardRequest(context, (ctx) => GqlExecutionContext.create(ctx).getContext());
|
|
349
351
|
}
|
|
350
352
|
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
} from '@nestjs/common';
|
|
9
9
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
10
10
|
|
|
11
|
+
import { resolveGuardRequest } from '../../common/helpers/execution-context-request.helper';
|
|
12
|
+
|
|
11
13
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
12
14
|
import { ErrorCode } from '../error-code';
|
|
13
15
|
import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../tenant/core-tenant.helpers';
|
|
@@ -178,19 +180,11 @@ export class BetterAuthRolesGuard implements CanActivate {
|
|
|
178
180
|
* Handles both GraphQL and HTTP contexts
|
|
179
181
|
*/
|
|
180
182
|
private getRequest(context: ExecutionContext): any {
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return ctx.req;
|
|
187
|
-
}
|
|
188
|
-
} catch {
|
|
189
|
-
// GraphQL context not available
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Fallback to HTTP context
|
|
193
|
-
return context.switchToHttp().getRequest();
|
|
183
|
+
// Shared with RolesGuard and CoreTenantGuard — see execution-context-request.helper. The GraphQL
|
|
184
|
+
// WEBSOCKET branch is what makes `@Roles()` work on a subscription at all: the subscription
|
|
185
|
+
// context carries the authenticated user (CoreModule's onConnect records it), while `.req` does
|
|
186
|
+
// not exist there and the HTTP fallback yields the resolver root.
|
|
187
|
+
return resolveGuardRequest(context, (ctx) => GqlExecutionContext.create(ctx).getContext());
|
|
194
188
|
}
|
|
195
189
|
|
|
196
190
|
/**
|
|
@@ -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
|