@flowerforce/flowerbase 1.8.4-beta.3 → 1.8.4-beta.5
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/README.md +3 -0
- package/dist/auth/plugins/jwt.d.ts +1 -1
- package/dist/auth/plugins/jwt.d.ts.map +1 -1
- package/dist/features/functions/controller.d.ts.map +1 -1
- package/dist/features/functions/controller.js +29 -2
- package/dist/features/functions/dtos.d.ts +2 -0
- package/dist/features/functions/dtos.d.ts.map +1 -1
- package/dist/features/functions/interface.d.ts +2 -0
- package/dist/features/functions/interface.d.ts.map +1 -1
- package/dist/features/functions/utils.d.ts +3 -1
- package/dist/features/functions/utils.d.ts.map +1 -1
- package/dist/features/functions/utils.js +3 -1
- package/dist/services/index.d.ts +8 -8
- package/dist/services/mongodb-atlas/index.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/index.js +112 -29
- package/dist/services/mongodb-atlas/model.d.ts +2 -0
- package/dist/services/mongodb-atlas/model.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/utils.d.ts +15 -0
- package/dist/services/mongodb-atlas/utils.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/utils.js +74 -12
- package/dist/utils/context/helpers.d.ts +24 -24
- package/dist/utils/roles/machines/read/D/validators.d.ts +1 -1
- package/dist/utils/roles/machines/read/D/validators.d.ts.map +1 -1
- package/dist/utils/roles/machines/write/C/validators.d.ts +1 -1
- package/dist/utils/roles/machines/write/C/validators.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/functions/__tests__/controller.test.ts +42 -2
- package/src/features/functions/__tests__/utils.test.ts +44 -0
- package/src/features/functions/controller.ts +36 -1
- package/src/features/functions/dtos.ts +2 -0
- package/src/features/functions/interface.ts +2 -0
- package/src/features/functions/utils.ts +13 -0
- package/src/services/mongodb-atlas/__tests__/realmCompatibility.test.ts +116 -0
- package/src/services/mongodb-atlas/__tests__/utils.test.ts +86 -1
- package/src/services/mongodb-atlas/index.ts +164 -44
- package/src/services/mongodb-atlas/model.ts +8 -0
- package/src/services/mongodb-atlas/utils.ts +81 -12
- package/tsconfig.json +0 -5
- package/tsconfig.spec.json +2 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ensureClientPipelineStages, getHiddenFieldsFromRulesConfig, prependUnsetStage, applyAccessControlToPipeline } from '../utils'
|
|
1
|
+
import { ensureClientPipelineStages, getHiddenFieldsFromRulesConfig, prependUnsetStage, applyAccessControlToPipeline, mergeProjections } from '../utils'
|
|
2
2
|
import { Role } from '../../../utils/roles/interface'
|
|
3
3
|
|
|
4
4
|
describe('MongoDB Atlas aggregate helpers', () => {
|
|
@@ -165,4 +165,89 @@ describe('MongoDB Atlas aggregate helpers', () => {
|
|
|
165
165
|
})
|
|
166
166
|
})
|
|
167
167
|
})
|
|
168
|
+
|
|
169
|
+
describe('mergeProjections', () => {
|
|
170
|
+
it('returns undefined when both sides are empty', () => {
|
|
171
|
+
expect(mergeProjections(undefined, undefined)).toBeUndefined()
|
|
172
|
+
expect(mergeProjections({}, null)).toBeUndefined()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('returns the client projection when rules have none', () => {
|
|
176
|
+
expect(mergeProjections({ a: 1, b: 1 }, null)).toEqual({ a: 1, b: 1 })
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('normalizes the rules projection when client has none', () => {
|
|
180
|
+
// Mixed inclusion/exclusion rules are normalized to pure inclusion mode.
|
|
181
|
+
expect(
|
|
182
|
+
mergeProjections(undefined, { item: 1, status: 1, instock: 0 })
|
|
183
|
+
).toEqual({ item: 1, status: 1 })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('merges plain inclusion projections (rules wins on conflict)', () => {
|
|
187
|
+
expect(
|
|
188
|
+
mergeProjections(
|
|
189
|
+
{ item: 1, price: 1 },
|
|
190
|
+
{ item: 1, status: 1 }
|
|
191
|
+
)
|
|
192
|
+
).toEqual({ item: 1, status: 1, price: 1 })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('supports dotted client keys alongside plain rules keys', () => {
|
|
196
|
+
expect(
|
|
197
|
+
mergeProjections(
|
|
198
|
+
{ price: 1 },
|
|
199
|
+
{ item: 1, status: 1, 'instock.qty': 1 }
|
|
200
|
+
)
|
|
201
|
+
).toEqual({ item: 1, status: 1, 'instock.qty': 1, price: 1 })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('drops client dotted keys when rules exclude the top-level field', () => {
|
|
205
|
+
// Rules: include item/status, exclude the whole `instock` subtree.
|
|
206
|
+
// Client tries to read `instock.qty` — it must be stripped.
|
|
207
|
+
expect(
|
|
208
|
+
mergeProjections(
|
|
209
|
+
{ item: 1, status: 1, 'instock.qty': 1 },
|
|
210
|
+
{ item: 1, status: 1, instock: 0 }
|
|
211
|
+
)
|
|
212
|
+
).toEqual({ item: 1, status: 1 })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('drops every client inclusion whose top-level is excluded by rules', () => {
|
|
216
|
+
expect(
|
|
217
|
+
mergeProjections(
|
|
218
|
+
{
|
|
219
|
+
item: 1,
|
|
220
|
+
'instock.qty': 1,
|
|
221
|
+
'instock.warehouse': 1,
|
|
222
|
+
price: 1
|
|
223
|
+
},
|
|
224
|
+
{ item: 1, instock: 0 }
|
|
225
|
+
)
|
|
226
|
+
).toEqual({ item: 1, price: 1 })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('produces pure exclusion output when neither side has inclusions', () => {
|
|
230
|
+
expect(
|
|
231
|
+
mergeProjections({ secretA: 0 }, { secretB: 0 })
|
|
232
|
+
).toEqual({ secretA: 0, secretB: 0 })
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('drops non-_id client exclusions when switching to inclusion mode', () => {
|
|
236
|
+
// Can't mix `{ price: 0, item: 1 }` in MongoDB — rules force inclusion
|
|
237
|
+
// mode so the client exclusion is silently dropped (price is implicitly
|
|
238
|
+
// excluded because it is not included).
|
|
239
|
+
expect(
|
|
240
|
+
mergeProjections({ price: 0 }, { item: 1 })
|
|
241
|
+
).toEqual({ item: 1 })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('keeps _id: 0 alongside inclusion mode', () => {
|
|
245
|
+
expect(
|
|
246
|
+
mergeProjections({ _id: 0 }, { item: 1 })
|
|
247
|
+
).toEqual({ _id: 0, item: 1 })
|
|
248
|
+
expect(
|
|
249
|
+
mergeProjections({ item: 1 }, { _id: 0 })
|
|
250
|
+
).toEqual({ _id: 0, item: 1 })
|
|
251
|
+
})
|
|
252
|
+
})
|
|
168
253
|
})
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
getFormattedProjection,
|
|
36
36
|
getFormattedQuery,
|
|
37
37
|
getHiddenFieldsFromRulesConfig,
|
|
38
|
+
mergeProjections,
|
|
38
39
|
normalizeQuery
|
|
39
40
|
} from './utils'
|
|
40
41
|
|
|
@@ -491,6 +492,26 @@ const areUpdatedFieldsAllowed = (
|
|
|
491
492
|
return updatedPaths.every((path) => isEqual(get(filtered, path), get(updated, path)))
|
|
492
493
|
}
|
|
493
494
|
|
|
495
|
+
const appendDistinctValue = (values: unknown[], candidate: unknown) => {
|
|
496
|
+
if (typeof candidate === 'undefined') return
|
|
497
|
+
if (!values.some((entry) => isEqual(entry, candidate))) {
|
|
498
|
+
values.push(candidate)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const collectDistinctValues = (documents: Document[], key: string) =>
|
|
503
|
+
documents.reduce<unknown[]>((values, document) => {
|
|
504
|
+
const currentValue = get(document, key)
|
|
505
|
+
|
|
506
|
+
if (Array.isArray(currentValue)) {
|
|
507
|
+
currentValue.forEach((entry) => appendDistinctValue(values, entry))
|
|
508
|
+
return values
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
appendDistinctValue(values, currentValue)
|
|
512
|
+
return values
|
|
513
|
+
}, [])
|
|
514
|
+
|
|
494
515
|
const getOperators: GetOperatorsFunction = (
|
|
495
516
|
mongo,
|
|
496
517
|
{ rules, dbName, collName, user, run_as_system, monitoringOrigin }
|
|
@@ -534,7 +555,48 @@ const getOperators: GetOperatorsFunction = (
|
|
|
534
555
|
})
|
|
535
556
|
}
|
|
536
557
|
|
|
537
|
-
|
|
558
|
+
const validateReadableDocument = async (currentDoc: Document) => {
|
|
559
|
+
const winningRole = await getWinningRoleAsync(currentDoc, user, roles)
|
|
560
|
+
|
|
561
|
+
logDebug('find winningRole', {
|
|
562
|
+
collection: collName,
|
|
563
|
+
userId: getUserId(user),
|
|
564
|
+
winningRoleName: winningRole?.name ?? null,
|
|
565
|
+
rolesLength: roles.length
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
const { status, document } = winningRole
|
|
569
|
+
? await checkValidation(
|
|
570
|
+
winningRole,
|
|
571
|
+
{
|
|
572
|
+
type: 'read',
|
|
573
|
+
roles,
|
|
574
|
+
cursor: currentDoc,
|
|
575
|
+
expansions: getValidationExpansions(currentDoc)
|
|
576
|
+
},
|
|
577
|
+
user
|
|
578
|
+
)
|
|
579
|
+
: fallbackAccess(currentDoc)
|
|
580
|
+
|
|
581
|
+
return status ? document : undefined
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const getScopedQuery = (query: MongoFilter<Document> = {}) => {
|
|
585
|
+
const formattedQuery = getFormattedQuery(filters, query, user)
|
|
586
|
+
const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {}
|
|
587
|
+
const safeQuery = Array.isArray(formattedQuery)
|
|
588
|
+
? normalizeQuery(formattedQuery)
|
|
589
|
+
: formattedQuery
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
formattedQuery,
|
|
593
|
+
currentQuery,
|
|
594
|
+
safeQuery,
|
|
595
|
+
builtQuery: buildAndQuery(safeQuery)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const operators: ReturnType<GetOperatorsFunction> = {
|
|
538
600
|
/**
|
|
539
601
|
* Finds a single document in a MongoDB collection with optional role-based filtering and validation.
|
|
540
602
|
*
|
|
@@ -558,13 +620,6 @@ const getOperators: GetOperatorsFunction = (
|
|
|
558
620
|
projectionOrOptions,
|
|
559
621
|
options
|
|
560
622
|
)
|
|
561
|
-
const resolvedOptions =
|
|
562
|
-
projection || normalizedOptions
|
|
563
|
-
? {
|
|
564
|
-
...(normalizedOptions ?? {}),
|
|
565
|
-
...(projection ? { projection } : {})
|
|
566
|
-
}
|
|
567
|
-
: undefined
|
|
568
623
|
const resolvedQuery = query ?? {}
|
|
569
624
|
if (!run_as_system) {
|
|
570
625
|
checkDenyOperation(
|
|
@@ -574,6 +629,17 @@ const getOperators: GetOperatorsFunction = (
|
|
|
574
629
|
)
|
|
575
630
|
// Apply access control filters to the query
|
|
576
631
|
const formattedQuery = getFormattedQuery(filters, resolvedQuery, user)
|
|
632
|
+
// Rules-level projection has priority over client-provided projection.
|
|
633
|
+
// The merged projection is passed natively to MongoDB.
|
|
634
|
+
const rulesProjection = getFormattedProjection(filters, user)
|
|
635
|
+
const finalProjection = mergeProjections(projection, rulesProjection)
|
|
636
|
+
const resolvedOptions =
|
|
637
|
+
finalProjection || normalizedOptions
|
|
638
|
+
? {
|
|
639
|
+
...(normalizedOptions ?? {}),
|
|
640
|
+
...(finalProjection ? { projection: finalProjection } : {})
|
|
641
|
+
}
|
|
642
|
+
: undefined
|
|
577
643
|
logDebug('update formattedQuery', {
|
|
578
644
|
collection: collName,
|
|
579
645
|
query,
|
|
@@ -629,8 +695,15 @@ const getOperators: GetOperatorsFunction = (
|
|
|
629
695
|
emitMongoEvent('findOne')
|
|
630
696
|
return Promise.resolve(response)
|
|
631
697
|
}
|
|
632
|
-
// System mode: no validation applied
|
|
633
|
-
const
|
|
698
|
+
// System mode: no validation applied, only client-provided projection/options.
|
|
699
|
+
const systemOptions =
|
|
700
|
+
projection || normalizedOptions
|
|
701
|
+
? {
|
|
702
|
+
...(normalizedOptions ?? {}),
|
|
703
|
+
...(projection ? { projection } : {})
|
|
704
|
+
}
|
|
705
|
+
: undefined
|
|
706
|
+
const response = await collection.findOne(resolvedQuery, systemOptions)
|
|
634
707
|
emitMongoEvent('findOne')
|
|
635
708
|
return response
|
|
636
709
|
} catch (error) {
|
|
@@ -1023,13 +1096,6 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1023
1096
|
projectionOrOptions,
|
|
1024
1097
|
options
|
|
1025
1098
|
)
|
|
1026
|
-
const resolvedOptions =
|
|
1027
|
-
projection || normalizedOptions
|
|
1028
|
-
? {
|
|
1029
|
-
...(normalizedOptions ?? {}),
|
|
1030
|
-
...(projection ? { projection } : {})
|
|
1031
|
-
}
|
|
1032
|
-
: undefined
|
|
1033
1099
|
if (!run_as_system) {
|
|
1034
1100
|
checkDenyOperation(
|
|
1035
1101
|
normalizedRules,
|
|
@@ -1039,6 +1105,17 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1039
1105
|
// Pre-query filtering based on access control rules
|
|
1040
1106
|
const formattedQuery = getFormattedQuery(filters, query, user)
|
|
1041
1107
|
const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {}
|
|
1108
|
+
// Rules-level projection has priority over client-provided projection.
|
|
1109
|
+
// The merged projection is passed natively to MongoDB.
|
|
1110
|
+
const rulesProjection = getFormattedProjection(filters, user)
|
|
1111
|
+
const finalProjection = mergeProjections(projection, rulesProjection)
|
|
1112
|
+
const resolvedOptions =
|
|
1113
|
+
finalProjection || normalizedOptions
|
|
1114
|
+
? {
|
|
1115
|
+
...(normalizedOptions ?? {}),
|
|
1116
|
+
...(finalProjection ? { projection: finalProjection } : {})
|
|
1117
|
+
}
|
|
1118
|
+
: undefined
|
|
1042
1119
|
// aggiunto filter per evitare questo errore: $and argument's entries must be objects
|
|
1043
1120
|
const cursor = collection.find(currentQuery, resolvedOptions)
|
|
1044
1121
|
const originalToArray = cursor.toArray.bind(cursor)
|
|
@@ -1052,30 +1129,7 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1052
1129
|
const response = await originalToArray()
|
|
1053
1130
|
|
|
1054
1131
|
const filteredResponse = await Promise.all(
|
|
1055
|
-
response.map(
|
|
1056
|
-
const winningRole = await getWinningRoleAsync(currentDoc, user, roles)
|
|
1057
|
-
|
|
1058
|
-
logDebug('find winningRole', {
|
|
1059
|
-
collection: collName,
|
|
1060
|
-
userId: getUserId(user),
|
|
1061
|
-
winningRoleName: winningRole?.name ?? null,
|
|
1062
|
-
rolesLength: roles.length
|
|
1063
|
-
})
|
|
1064
|
-
const { status, document } = winningRole
|
|
1065
|
-
? await checkValidation(
|
|
1066
|
-
winningRole,
|
|
1067
|
-
{
|
|
1068
|
-
type: 'read',
|
|
1069
|
-
roles,
|
|
1070
|
-
cursor: currentDoc,
|
|
1071
|
-
expansions: getValidationExpansions(currentDoc)
|
|
1072
|
-
},
|
|
1073
|
-
user
|
|
1074
|
-
)
|
|
1075
|
-
: fallbackAccess(currentDoc)
|
|
1076
|
-
|
|
1077
|
-
return status ? document : undefined
|
|
1078
|
-
})
|
|
1132
|
+
response.map((currentDoc) => validateReadableDocument(currentDoc))
|
|
1079
1133
|
)
|
|
1080
1134
|
|
|
1081
1135
|
return filteredResponse.filter(Boolean) as WithId<Document>[]
|
|
@@ -1084,8 +1138,15 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1084
1138
|
emitMongoEvent('find')
|
|
1085
1139
|
return cursor
|
|
1086
1140
|
}
|
|
1087
|
-
// System mode: return original unfiltered cursor
|
|
1088
|
-
const
|
|
1141
|
+
// System mode: return original unfiltered cursor (only client projection/options).
|
|
1142
|
+
const systemOptions =
|
|
1143
|
+
projection || normalizedOptions
|
|
1144
|
+
? {
|
|
1145
|
+
...(normalizedOptions ?? {}),
|
|
1146
|
+
...(projection ? { projection } : {})
|
|
1147
|
+
}
|
|
1148
|
+
: undefined
|
|
1149
|
+
const cursor = collection.find(query, systemOptions)
|
|
1089
1150
|
emitMongoEvent('find')
|
|
1090
1151
|
return cursor
|
|
1091
1152
|
} catch (error) {
|
|
@@ -1141,6 +1202,45 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1141
1202
|
throw error
|
|
1142
1203
|
}
|
|
1143
1204
|
},
|
|
1205
|
+
distinct: async (key, query = {}, options) => {
|
|
1206
|
+
try {
|
|
1207
|
+
if (!key) {
|
|
1208
|
+
throw new Error('distinct key is required')
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (!run_as_system) {
|
|
1212
|
+
checkDenyOperation(
|
|
1213
|
+
normalizedRules,
|
|
1214
|
+
collection.collectionName,
|
|
1215
|
+
CRUD_OPERATIONS.READ
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
const { currentQuery } = getScopedQuery(query)
|
|
1219
|
+
const projectedOptions = {
|
|
1220
|
+
...(options ?? {}),
|
|
1221
|
+
projection: { _id: 1, [key]: 1 }
|
|
1222
|
+
} as FindOptions
|
|
1223
|
+
const documents = await collection.find(currentQuery, projectedOptions).toArray()
|
|
1224
|
+
const readableDocuments = (
|
|
1225
|
+
await Promise.all(documents.map((currentDoc) => validateReadableDocument(currentDoc)))
|
|
1226
|
+
).filter(Boolean) as Document[]
|
|
1227
|
+
const result = collectDistinctValues(readableDocuments, key)
|
|
1228
|
+
|
|
1229
|
+
emitMongoEvent('distinct')
|
|
1230
|
+
return result
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const result =
|
|
1234
|
+
typeof options === 'undefined'
|
|
1235
|
+
? await collection.distinct(key, query)
|
|
1236
|
+
: await collection.distinct(key, query, options)
|
|
1237
|
+
emitMongoEvent('distinct')
|
|
1238
|
+
return result
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
emitMongoEvent('distinct', undefined, error)
|
|
1241
|
+
throw error
|
|
1242
|
+
}
|
|
1243
|
+
},
|
|
1144
1244
|
/**
|
|
1145
1245
|
* Watches changes on a MongoDB collection with optional role-based filtering of change events.
|
|
1146
1246
|
*
|
|
@@ -1327,7 +1427,7 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1327
1427
|
formattedQuery,
|
|
1328
1428
|
pipeline
|
|
1329
1429
|
})
|
|
1330
|
-
const projection = getFormattedProjection(filters)
|
|
1430
|
+
const projection = getFormattedProjection(filters, user)
|
|
1331
1431
|
const hiddenFields = getHiddenFieldsFromRulesConfig(rulesConfig)
|
|
1332
1432
|
|
|
1333
1433
|
const sanitizedPipeline = applyAccessControlToPipeline(
|
|
@@ -1427,6 +1527,24 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1427
1527
|
throw error
|
|
1428
1528
|
}
|
|
1429
1529
|
},
|
|
1530
|
+
bulkWrite: async (operations, options) => {
|
|
1531
|
+
try {
|
|
1532
|
+
if (!run_as_system) {
|
|
1533
|
+
throw new Error('bulkWrite is available only when run_as_system is enabled')
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const result = await collection.bulkWrite(operations, options)
|
|
1537
|
+
emitMongoEvent('bulkWrite', { operations: operations.length })
|
|
1538
|
+
return result
|
|
1539
|
+
} catch (error) {
|
|
1540
|
+
emitMongoEvent(
|
|
1541
|
+
'bulkWrite',
|
|
1542
|
+
{ operations: Array.isArray(operations) ? operations.length : 0 },
|
|
1543
|
+
error
|
|
1544
|
+
)
|
|
1545
|
+
throw error
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1430
1548
|
updateMany: async (query, data, options) => {
|
|
1431
1549
|
try {
|
|
1432
1550
|
const normalizedData = normalizeUpdatePayload(data as Document)
|
|
@@ -1579,6 +1697,8 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1579
1697
|
}
|
|
1580
1698
|
}
|
|
1581
1699
|
}
|
|
1700
|
+
|
|
1701
|
+
return operators
|
|
1582
1702
|
}
|
|
1583
1703
|
|
|
1584
1704
|
const MongodbAtlas: MongodbAtlasFunction = (
|
|
@@ -93,6 +93,14 @@ export type GetOperatorsFunction = (
|
|
|
93
93
|
aggregate: (
|
|
94
94
|
...params: [...Parameters<Method<'aggregate'>>, isClient: boolean]
|
|
95
95
|
) => ReturnType<Method<'aggregate'>>
|
|
96
|
+
distinct: (
|
|
97
|
+
key: Parameters<Method<'distinct'>>[0],
|
|
98
|
+
filter?: Parameters<Method<'distinct'>>[1],
|
|
99
|
+
options?: Parameters<Method<'distinct'>>[2]
|
|
100
|
+
) => ReturnType<Method<'distinct'>>
|
|
101
|
+
bulkWrite: (
|
|
102
|
+
...params: Parameters<Method<'bulkWrite'>>
|
|
103
|
+
) => ReturnType<Method<'bulkWrite'>>
|
|
96
104
|
insertMany: (
|
|
97
105
|
...params: Parameters<Method<'insertMany'>>
|
|
98
106
|
) => ReturnType<Method<'insertMany'>>
|
|
@@ -76,20 +76,89 @@ export const getFormattedProjection = (
|
|
|
76
76
|
filters: Filter[] = [],
|
|
77
77
|
user?: User
|
|
78
78
|
): Projection | null => {
|
|
79
|
-
const projections = filters
|
|
80
|
-
.filter((
|
|
81
|
-
|
|
82
|
-
const preFilter = getValidRule({ filters, user })
|
|
83
|
-
const isValidPreFilter = !!preFilter?.length
|
|
84
|
-
return isValidPreFilter
|
|
85
|
-
}
|
|
86
|
-
return false
|
|
87
|
-
})
|
|
88
|
-
.map((f) => f.projection)
|
|
79
|
+
const projections = getValidRule({ filters, user })
|
|
80
|
+
.filter((f) => !!f.projection)
|
|
81
|
+
.map((f) => f.projection as Projection)
|
|
89
82
|
if (!projections.length) return null
|
|
90
83
|
return Object.assign({}, ...projections)
|
|
91
84
|
}
|
|
92
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Merges a client-provided projection with the one computed from rules filters.
|
|
88
|
+
*
|
|
89
|
+
* Rules have higher priority over the client:
|
|
90
|
+
* - If rules exclude a top-level field (e.g. `{ instock: 0 }`), every client
|
|
91
|
+
* reference to that field — including dotted sub-paths such as
|
|
92
|
+
* `"instock.qty": 1` — is dropped from the final projection.
|
|
93
|
+
* - If rules include a field (value `1`), it is always part of the final
|
|
94
|
+
* projection and overrides any conflicting client value.
|
|
95
|
+
* - The returned projection is always a valid MongoDB projection (no mixing of
|
|
96
|
+
* inclusion and exclusion on non-`_id` keys), so it can be passed as-is to
|
|
97
|
+
* native MongoDB methods.
|
|
98
|
+
* - Returns `undefined` when neither side provided a meaningful projection.
|
|
99
|
+
*/
|
|
100
|
+
export const mergeProjections = (
|
|
101
|
+
clientProjection: Projection | Document | undefined,
|
|
102
|
+
rulesProjection: Projection | null | undefined
|
|
103
|
+
): Projection | Document | undefined => {
|
|
104
|
+
const hasClient = !!clientProjection && Object.keys(clientProjection).length > 0
|
|
105
|
+
const hasRules = !!rulesProjection && Object.keys(rulesProjection).length > 0
|
|
106
|
+
if (!hasClient && !hasRules) return undefined
|
|
107
|
+
|
|
108
|
+
const client = (hasClient ? (clientProjection as Projection) : {}) as Projection
|
|
109
|
+
const rules = (hasRules ? (rulesProjection as Projection) : {}) as Projection
|
|
110
|
+
|
|
111
|
+
const getTopLevel = (key: string) => key.split('.')[0]
|
|
112
|
+
|
|
113
|
+
const rulesEntries = Object.entries(rules)
|
|
114
|
+
const rulesIncludeKeys = rulesEntries
|
|
115
|
+
.filter(([, value]) => value === 1)
|
|
116
|
+
.map(([key]) => key)
|
|
117
|
+
const rulesExcludeKeys = rulesEntries
|
|
118
|
+
.filter(([, value]) => value === 0)
|
|
119
|
+
.map(([key]) => key)
|
|
120
|
+
|
|
121
|
+
// Top-level fields excluded by rules (excluding `_id` which has special
|
|
122
|
+
// MongoDB semantics and is allowed alongside inclusion projections).
|
|
123
|
+
const excludedTopLevel = new Set(
|
|
124
|
+
rulesExcludeKeys.map(getTopLevel).filter((key) => key !== '_id')
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const filteredClient: Record<string, 0 | 1> = {}
|
|
128
|
+
for (const [key, value] of Object.entries(client)) {
|
|
129
|
+
if (excludedTopLevel.has(getTopLevel(key))) continue
|
|
130
|
+
filteredClient[key] = value as 0 | 1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const hasInclusion =
|
|
134
|
+
rulesIncludeKeys.some((key) => key !== '_id') ||
|
|
135
|
+
Object.entries(filteredClient).some(([key, value]) => value === 1 && key !== '_id')
|
|
136
|
+
|
|
137
|
+
const merged: Record<string, 0 | 1> = {}
|
|
138
|
+
|
|
139
|
+
if (hasInclusion) {
|
|
140
|
+
// Inclusion mode: keep only client inclusions, then overlay rules inclusions.
|
|
141
|
+
// Client exclusions (other than `_id: 0`) are incompatible with inclusion
|
|
142
|
+
// mode and are dropped; not-included fields are implicitly excluded anyway.
|
|
143
|
+
for (const [key, value] of Object.entries(filteredClient)) {
|
|
144
|
+
if (value === 1 || key === '_id') merged[key] = value
|
|
145
|
+
}
|
|
146
|
+
for (const key of rulesIncludeKeys) merged[key] = 1
|
|
147
|
+
// Allow `_id: 0` to be forced by rules in inclusion mode.
|
|
148
|
+
for (const key of rulesExcludeKeys) {
|
|
149
|
+
if (key === '_id') merged[key] = 0
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// Pure exclusion mode: combine all exclusions from both sides.
|
|
153
|
+
for (const [key, value] of Object.entries(filteredClient)) {
|
|
154
|
+
if (value === 0) merged[key] = 0
|
|
155
|
+
}
|
|
156
|
+
for (const key of rulesExcludeKeys) merged[key] = 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return Object.keys(merged).length > 0 ? merged : undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
93
162
|
export const applyAccessControlToPipeline = (
|
|
94
163
|
pipeline: AggregationPipeline,
|
|
95
164
|
rules: Record<
|
|
@@ -120,7 +189,7 @@ export const applyAccessControlToPipeline = (
|
|
|
120
189
|
checkDenyOperation(rules as Rules, currentCollection, CRUD_OPERATIONS.READ)
|
|
121
190
|
const lookupRules = rules[currentCollection] || {}
|
|
122
191
|
const formattedQuery = getFormattedQuery(lookupRules.filters, {}, user)
|
|
123
|
-
const projection = getFormattedProjection(lookupRules.filters)
|
|
192
|
+
const projection = getFormattedProjection(lookupRules.filters, user)
|
|
124
193
|
|
|
125
194
|
const nestedPipeline = applyAccessControlToPipeline(
|
|
126
195
|
lookUpStage.pipeline || [],
|
|
@@ -155,7 +224,7 @@ export const applyAccessControlToPipeline = (
|
|
|
155
224
|
checkDenyOperation(rules as Rules, currentCollection, CRUD_OPERATIONS.READ)
|
|
156
225
|
const unionRules = rules[currentCollection] || {}
|
|
157
226
|
const formattedQuery = getFormattedQuery(unionRules.filters, {}, user)
|
|
158
|
-
const projection = getFormattedProjection(unionRules.filters)
|
|
227
|
+
const projection = getFormattedProjection(unionRules.filters, user)
|
|
159
228
|
|
|
160
229
|
if (isSimpleStage) {
|
|
161
230
|
return stage
|
package/tsconfig.json
CHANGED
|
@@ -8,13 +8,8 @@
|
|
|
8
8
|
"declarationMap": true,
|
|
9
9
|
"noImplicitAny": true,
|
|
10
10
|
"strict": true,
|
|
11
|
-
"moduleResolution": "node",
|
|
12
11
|
"esModuleInterop": true,
|
|
13
12
|
"skipLibCheck": true,
|
|
14
|
-
"baseUrl": ".",
|
|
15
|
-
"paths": {
|
|
16
|
-
"*": ["../../node_modules/*"]
|
|
17
|
-
},
|
|
18
13
|
"lib": ["ES2021", "DOM"]
|
|
19
14
|
},
|
|
20
15
|
"include": ["src/**/*"],
|