@flowerforce/flowerbase 1.8.4-beta.3 → 1.8.4-beta.4
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/dist/services/mongodb-atlas/index.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/index.js +21 -9
- 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/package.json +1 -1
- package/src/services/mongodb-atlas/__tests__/utils.test.ts +86 -1
- package/src/services/mongodb-atlas/index.ts +42 -19
- package/src/services/mongodb-atlas/utils.ts +81 -12
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAIL,QAAQ,EAQT,MAAM,SAAS,CAAA;AAOhB,OAAO,EAGL,oBAAoB,EAErB,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAIL,QAAQ,EAQT,MAAM,SAAS,CAAA;AAOhB,OAAO,EAGL,oBAAoB,EAErB,MAAM,SAAS,CAAA;AA8JhB,eAAO,MAAM,kBAAkB,GAAI,OAAO,OAAO,KAAG,OA0BnD,CAAA;AA8BD,eAAO,MAAM,2BAA2B,GAAI,UAAU,QAAQ,EAAE,YAK5D,CAAA;AA80CJ,QAAA,MAAM,YAAY,EAAE,oBAwBlB,CAAA;AAEF,eAAe,YAAY,CAAA"}
|
|
@@ -459,13 +459,17 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
|
|
|
459
459
|
var _a;
|
|
460
460
|
try {
|
|
461
461
|
const { projection, options: normalizedOptions } = resolveFindArgs(projectionOrOptions, options);
|
|
462
|
-
const resolvedOptions = projection || normalizedOptions
|
|
463
|
-
? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
|
|
464
462
|
const resolvedQuery = query !== null && query !== void 0 ? query : {};
|
|
465
463
|
if (!run_as_system) {
|
|
466
464
|
(0, utils_3.checkDenyOperation)(normalizedRules, collection.collectionName, model_1.CRUD_OPERATIONS.READ);
|
|
467
465
|
// Apply access control filters to the query
|
|
468
466
|
const formattedQuery = (0, utils_3.getFormattedQuery)(filters, resolvedQuery, user);
|
|
467
|
+
// Rules-level projection has priority over client-provided projection.
|
|
468
|
+
// The merged projection is passed natively to MongoDB.
|
|
469
|
+
const rulesProjection = (0, utils_3.getFormattedProjection)(filters, user);
|
|
470
|
+
const finalProjection = (0, utils_3.mergeProjections)(projection, rulesProjection);
|
|
471
|
+
const resolvedOptions = finalProjection || normalizedOptions
|
|
472
|
+
? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (finalProjection ? { projection: finalProjection } : {})) : undefined;
|
|
469
473
|
logDebug('update formattedQuery', {
|
|
470
474
|
collection: collName,
|
|
471
475
|
query,
|
|
@@ -509,8 +513,10 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
|
|
|
509
513
|
emitMongoEvent('findOne');
|
|
510
514
|
return Promise.resolve(response);
|
|
511
515
|
}
|
|
512
|
-
// System mode: no validation applied
|
|
513
|
-
const
|
|
516
|
+
// System mode: no validation applied, only client-provided projection/options.
|
|
517
|
+
const systemOptions = projection || normalizedOptions
|
|
518
|
+
? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
|
|
519
|
+
const response = yield collection.findOne(resolvedQuery, systemOptions);
|
|
514
520
|
emitMongoEvent('findOne');
|
|
515
521
|
return response;
|
|
516
522
|
}
|
|
@@ -825,13 +831,17 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
|
|
|
825
831
|
find: (query = {}, projectionOrOptions, options) => {
|
|
826
832
|
try {
|
|
827
833
|
const { projection, options: normalizedOptions } = resolveFindArgs(projectionOrOptions, options);
|
|
828
|
-
const resolvedOptions = projection || normalizedOptions
|
|
829
|
-
? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
|
|
830
834
|
if (!run_as_system) {
|
|
831
835
|
(0, utils_3.checkDenyOperation)(normalizedRules, collection.collectionName, model_1.CRUD_OPERATIONS.READ);
|
|
832
836
|
// Pre-query filtering based on access control rules
|
|
833
837
|
const formattedQuery = (0, utils_3.getFormattedQuery)(filters, query, user);
|
|
834
838
|
const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {};
|
|
839
|
+
// Rules-level projection has priority over client-provided projection.
|
|
840
|
+
// The merged projection is passed natively to MongoDB.
|
|
841
|
+
const rulesProjection = (0, utils_3.getFormattedProjection)(filters, user);
|
|
842
|
+
const finalProjection = (0, utils_3.mergeProjections)(projection, rulesProjection);
|
|
843
|
+
const resolvedOptions = finalProjection || normalizedOptions
|
|
844
|
+
? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (finalProjection ? { projection: finalProjection } : {})) : undefined;
|
|
835
845
|
// aggiunto filter per evitare questo errore: $and argument's entries must be objects
|
|
836
846
|
const cursor = collection.find(currentQuery, resolvedOptions);
|
|
837
847
|
const originalToArray = cursor.toArray.bind(cursor);
|
|
@@ -866,8 +876,10 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
|
|
|
866
876
|
emitMongoEvent('find');
|
|
867
877
|
return cursor;
|
|
868
878
|
}
|
|
869
|
-
// System mode: return original unfiltered cursor
|
|
870
|
-
const
|
|
879
|
+
// System mode: return original unfiltered cursor (only client projection/options).
|
|
880
|
+
const systemOptions = projection || normalizedOptions
|
|
881
|
+
? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
|
|
882
|
+
const cursor = collection.find(query, systemOptions);
|
|
871
883
|
emitMongoEvent('find');
|
|
872
884
|
return cursor;
|
|
873
885
|
}
|
|
@@ -1041,7 +1053,7 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
|
|
|
1041
1053
|
formattedQuery,
|
|
1042
1054
|
pipeline
|
|
1043
1055
|
});
|
|
1044
|
-
const projection = (0, utils_3.getFormattedProjection)(filters);
|
|
1056
|
+
const projection = (0, utils_3.getFormattedProjection)(filters, user);
|
|
1045
1057
|
const hiddenFields = (0, utils_3.getHiddenFieldsFromRulesConfig)(rulesConfig);
|
|
1046
1058
|
const sanitizedPipeline = (0, utils_3.applyAccessControlToPipeline)(pipeline, normalizedRules, user, collName, { isClientPipeline: true });
|
|
1047
1059
|
logDebug('aggregate sanitizedPipeline', {
|
|
@@ -7,6 +7,21 @@ import { CRUD_OPERATIONS, GetValidRuleParams } from './model';
|
|
|
7
7
|
export declare const getValidRule: <T extends Role | Filter>({ filters, user, record }: GetValidRuleParams<T>) => T[];
|
|
8
8
|
export declare const getFormattedQuery: (filters?: Filter[], query?: Parameters<Collection<Document>["findOne"]>[0], user?: User) => FilterMongoDB<Document>[];
|
|
9
9
|
export declare const getFormattedProjection: (filters?: Filter[], user?: User) => Projection | null;
|
|
10
|
+
/**
|
|
11
|
+
* Merges a client-provided projection with the one computed from rules filters.
|
|
12
|
+
*
|
|
13
|
+
* Rules have higher priority over the client:
|
|
14
|
+
* - If rules exclude a top-level field (e.g. `{ instock: 0 }`), every client
|
|
15
|
+
* reference to that field — including dotted sub-paths such as
|
|
16
|
+
* `"instock.qty": 1` — is dropped from the final projection.
|
|
17
|
+
* - If rules include a field (value `1`), it is always part of the final
|
|
18
|
+
* projection and overrides any conflicting client value.
|
|
19
|
+
* - The returned projection is always a valid MongoDB projection (no mixing of
|
|
20
|
+
* inclusion and exclusion on non-`_id` keys), so it can be passed as-is to
|
|
21
|
+
* native MongoDB methods.
|
|
22
|
+
* - Returns `undefined` when neither side provided a meaningful projection.
|
|
23
|
+
*/
|
|
24
|
+
export declare const mergeProjections: (clientProjection: Projection | Document | undefined, rulesProjection: Projection | null | undefined) => Projection | Document | undefined;
|
|
10
25
|
export declare const applyAccessControlToPipeline: (pipeline: AggregationPipeline, rules: Record<string, {
|
|
11
26
|
filters?: Filter[];
|
|
12
27
|
roles?: Role[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAElC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,SAAS,CAAA;AACvE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AACtC,OAAO,EACL,mBAAmB,EAEnB,MAAM,EAEN,UAAU,EACV,KAAK,EAGN,MAAM,gCAAgC,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,6BAA6B,CAAA;AAGlD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAE7D,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS,IAAI,GAAG,MAAM,EAAE,2BAInD,kBAAkB,CAAC,CAAC,CAAC,QA8BvB,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC5B,UAAS,MAAM,EAAO,EACtB,QAAQ,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EACtD,OAAO,IAAI,8BAcZ,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,UAAS,MAAM,EAAO,EACtB,OAAO,IAAI,KACV,UAAU,GAAG,
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAElC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,SAAS,CAAA;AACvE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AACtC,OAAO,EACL,mBAAmB,EAEnB,MAAM,EAEN,UAAU,EACV,KAAK,EAGN,MAAM,gCAAgC,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,6BAA6B,CAAA;AAGlD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAE7D,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS,IAAI,GAAG,MAAM,EAAE,2BAInD,kBAAkB,CAAC,CAAC,CAAC,QA8BvB,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC5B,UAAS,MAAM,EAAO,EACtB,QAAQ,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EACtD,OAAO,IAAI,8BAcZ,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,UAAS,MAAM,EAAO,EACtB,OAAO,IAAI,KACV,UAAU,GAAG,IAMf,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAC3B,kBAAkB,UAAU,GAAG,QAAQ,GAAG,SAAS,EACnD,iBAAiB,UAAU,GAAG,IAAI,GAAG,SAAS,KAC7C,UAAU,GAAG,QAAQ,GAAG,SAyD1B,CAAA;AAED,eAAO,MAAM,4BAA4B,GACvC,UAAU,mBAAmB,EAC7B,OAAO,MAAM,CACX,MAAM,EACN;IACE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;CACf,CACF,EACD,MAAM,IAAI,EACV,gBAAgB,MAAM,EACtB,UAAU;IACR,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B,KACA,mBA6GF,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC7B,OAAO,KAAK,EACZ,gBAAgB,MAAM,EACtB,WAAW,eAAe,SAM3B,CAAA;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC,EAAE;;;;;;;;iBA0I2jkS,CAAC;sBAAgC,CAAC;2BAAsC,CAAC;;;;IAlIlskS;AAED,eAAO,MAAM,0BAA0B,GAAI,UAAU,QAAQ,EAAE,aAgC9D,CAAA;AAYD,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,mBAAmB,QA+BvE;AAED,wBAAgB,8BAA8B,CAAC,WAAW,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;CAAE,YAK9E;AAwCD,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,EAAE,uBAKtF"}
|
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.getCollectionsFromPipeline = exports.checkDenyOperation = exports.applyAccessControlToPipeline = exports.getFormattedProjection = exports.getFormattedQuery = exports.getValidRule = void 0;
|
|
6
|
+
exports.getCollectionsFromPipeline = exports.checkDenyOperation = exports.applyAccessControlToPipeline = exports.mergeProjections = exports.getFormattedProjection = exports.getFormattedQuery = exports.getValidRule = void 0;
|
|
7
7
|
exports.normalizeQuery = normalizeQuery;
|
|
8
8
|
exports.ensureClientPipelineStages = ensureClientPipelineStages;
|
|
9
9
|
exports.getHiddenFieldsFromRulesConfig = getHiddenFieldsFromRulesConfig;
|
|
@@ -48,21 +48,83 @@ const getFormattedQuery = (filters = [], query, user) => {
|
|
|
48
48
|
};
|
|
49
49
|
exports.getFormattedQuery = getFormattedQuery;
|
|
50
50
|
const getFormattedProjection = (filters = [], user) => {
|
|
51
|
-
const projections = filters
|
|
52
|
-
.filter((
|
|
53
|
-
if (filter.projection) {
|
|
54
|
-
const preFilter = (0, exports.getValidRule)({ filters, user });
|
|
55
|
-
const isValidPreFilter = !!(preFilter === null || preFilter === void 0 ? void 0 : preFilter.length);
|
|
56
|
-
return isValidPreFilter;
|
|
57
|
-
}
|
|
58
|
-
return false;
|
|
59
|
-
})
|
|
51
|
+
const projections = (0, exports.getValidRule)({ filters, user })
|
|
52
|
+
.filter((f) => !!f.projection)
|
|
60
53
|
.map((f) => f.projection);
|
|
61
54
|
if (!projections.length)
|
|
62
55
|
return null;
|
|
63
56
|
return Object.assign({}, ...projections);
|
|
64
57
|
};
|
|
65
58
|
exports.getFormattedProjection = getFormattedProjection;
|
|
59
|
+
/**
|
|
60
|
+
* Merges a client-provided projection with the one computed from rules filters.
|
|
61
|
+
*
|
|
62
|
+
* Rules have higher priority over the client:
|
|
63
|
+
* - If rules exclude a top-level field (e.g. `{ instock: 0 }`), every client
|
|
64
|
+
* reference to that field — including dotted sub-paths such as
|
|
65
|
+
* `"instock.qty": 1` — is dropped from the final projection.
|
|
66
|
+
* - If rules include a field (value `1`), it is always part of the final
|
|
67
|
+
* projection and overrides any conflicting client value.
|
|
68
|
+
* - The returned projection is always a valid MongoDB projection (no mixing of
|
|
69
|
+
* inclusion and exclusion on non-`_id` keys), so it can be passed as-is to
|
|
70
|
+
* native MongoDB methods.
|
|
71
|
+
* - Returns `undefined` when neither side provided a meaningful projection.
|
|
72
|
+
*/
|
|
73
|
+
const mergeProjections = (clientProjection, rulesProjection) => {
|
|
74
|
+
const hasClient = !!clientProjection && Object.keys(clientProjection).length > 0;
|
|
75
|
+
const hasRules = !!rulesProjection && Object.keys(rulesProjection).length > 0;
|
|
76
|
+
if (!hasClient && !hasRules)
|
|
77
|
+
return undefined;
|
|
78
|
+
const client = (hasClient ? clientProjection : {});
|
|
79
|
+
const rules = (hasRules ? rulesProjection : {});
|
|
80
|
+
const getTopLevel = (key) => key.split('.')[0];
|
|
81
|
+
const rulesEntries = Object.entries(rules);
|
|
82
|
+
const rulesIncludeKeys = rulesEntries
|
|
83
|
+
.filter(([, value]) => value === 1)
|
|
84
|
+
.map(([key]) => key);
|
|
85
|
+
const rulesExcludeKeys = rulesEntries
|
|
86
|
+
.filter(([, value]) => value === 0)
|
|
87
|
+
.map(([key]) => key);
|
|
88
|
+
// Top-level fields excluded by rules (excluding `_id` which has special
|
|
89
|
+
// MongoDB semantics and is allowed alongside inclusion projections).
|
|
90
|
+
const excludedTopLevel = new Set(rulesExcludeKeys.map(getTopLevel).filter((key) => key !== '_id'));
|
|
91
|
+
const filteredClient = {};
|
|
92
|
+
for (const [key, value] of Object.entries(client)) {
|
|
93
|
+
if (excludedTopLevel.has(getTopLevel(key)))
|
|
94
|
+
continue;
|
|
95
|
+
filteredClient[key] = value;
|
|
96
|
+
}
|
|
97
|
+
const hasInclusion = rulesIncludeKeys.some((key) => key !== '_id') ||
|
|
98
|
+
Object.entries(filteredClient).some(([key, value]) => value === 1 && key !== '_id');
|
|
99
|
+
const merged = {};
|
|
100
|
+
if (hasInclusion) {
|
|
101
|
+
// Inclusion mode: keep only client inclusions, then overlay rules inclusions.
|
|
102
|
+
// Client exclusions (other than `_id: 0`) are incompatible with inclusion
|
|
103
|
+
// mode and are dropped; not-included fields are implicitly excluded anyway.
|
|
104
|
+
for (const [key, value] of Object.entries(filteredClient)) {
|
|
105
|
+
if (value === 1 || key === '_id')
|
|
106
|
+
merged[key] = value;
|
|
107
|
+
}
|
|
108
|
+
for (const key of rulesIncludeKeys)
|
|
109
|
+
merged[key] = 1;
|
|
110
|
+
// Allow `_id: 0` to be forced by rules in inclusion mode.
|
|
111
|
+
for (const key of rulesExcludeKeys) {
|
|
112
|
+
if (key === '_id')
|
|
113
|
+
merged[key] = 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// Pure exclusion mode: combine all exclusions from both sides.
|
|
118
|
+
for (const [key, value] of Object.entries(filteredClient)) {
|
|
119
|
+
if (value === 0)
|
|
120
|
+
merged[key] = 0;
|
|
121
|
+
}
|
|
122
|
+
for (const key of rulesExcludeKeys)
|
|
123
|
+
merged[key] = 0;
|
|
124
|
+
}
|
|
125
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
126
|
+
};
|
|
127
|
+
exports.mergeProjections = mergeProjections;
|
|
66
128
|
const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, options) => {
|
|
67
129
|
const { isClientPipeline = false } = options || {};
|
|
68
130
|
const hiddenFieldsForCollection = isClientPipeline
|
|
@@ -77,7 +139,7 @@ const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, opt
|
|
|
77
139
|
(0, exports.checkDenyOperation)(rules, currentCollection, model_1.CRUD_OPERATIONS.READ);
|
|
78
140
|
const lookupRules = rules[currentCollection] || {};
|
|
79
141
|
const formattedQuery = (0, exports.getFormattedQuery)(lookupRules.filters, {}, user);
|
|
80
|
-
const projection = (0, exports.getFormattedProjection)(lookupRules.filters);
|
|
142
|
+
const projection = (0, exports.getFormattedProjection)(lookupRules.filters, user);
|
|
81
143
|
const nestedPipeline = (0, exports.applyAccessControlToPipeline)(lookUpStage.pipeline || [], rules, user, currentCollection, { isClientPipeline });
|
|
82
144
|
const lookupPipeline = [
|
|
83
145
|
...(formattedQuery.length ? [{ $match: { $and: formattedQuery } }] : []),
|
|
@@ -98,7 +160,7 @@ const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, opt
|
|
|
98
160
|
(0, exports.checkDenyOperation)(rules, currentCollection, model_1.CRUD_OPERATIONS.READ);
|
|
99
161
|
const unionRules = rules[currentCollection] || {};
|
|
100
162
|
const formattedQuery = (0, exports.getFormattedQuery)(unionRules.filters, {}, user);
|
|
101
|
-
const projection = (0, exports.getFormattedProjection)(unionRules.filters);
|
|
163
|
+
const projection = (0, exports.getFormattedProjection)(unionRules.filters, user);
|
|
102
164
|
if (isSimpleStage) {
|
|
103
165
|
return stage;
|
|
104
166
|
}
|
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -558,13 +559,6 @@ const getOperators: GetOperatorsFunction = (
|
|
|
558
559
|
projectionOrOptions,
|
|
559
560
|
options
|
|
560
561
|
)
|
|
561
|
-
const resolvedOptions =
|
|
562
|
-
projection || normalizedOptions
|
|
563
|
-
? {
|
|
564
|
-
...(normalizedOptions ?? {}),
|
|
565
|
-
...(projection ? { projection } : {})
|
|
566
|
-
}
|
|
567
|
-
: undefined
|
|
568
562
|
const resolvedQuery = query ?? {}
|
|
569
563
|
if (!run_as_system) {
|
|
570
564
|
checkDenyOperation(
|
|
@@ -574,6 +568,17 @@ const getOperators: GetOperatorsFunction = (
|
|
|
574
568
|
)
|
|
575
569
|
// Apply access control filters to the query
|
|
576
570
|
const formattedQuery = getFormattedQuery(filters, resolvedQuery, user)
|
|
571
|
+
// Rules-level projection has priority over client-provided projection.
|
|
572
|
+
// The merged projection is passed natively to MongoDB.
|
|
573
|
+
const rulesProjection = getFormattedProjection(filters, user)
|
|
574
|
+
const finalProjection = mergeProjections(projection, rulesProjection)
|
|
575
|
+
const resolvedOptions =
|
|
576
|
+
finalProjection || normalizedOptions
|
|
577
|
+
? {
|
|
578
|
+
...(normalizedOptions ?? {}),
|
|
579
|
+
...(finalProjection ? { projection: finalProjection } : {})
|
|
580
|
+
}
|
|
581
|
+
: undefined
|
|
577
582
|
logDebug('update formattedQuery', {
|
|
578
583
|
collection: collName,
|
|
579
584
|
query,
|
|
@@ -629,8 +634,15 @@ const getOperators: GetOperatorsFunction = (
|
|
|
629
634
|
emitMongoEvent('findOne')
|
|
630
635
|
return Promise.resolve(response)
|
|
631
636
|
}
|
|
632
|
-
// System mode: no validation applied
|
|
633
|
-
const
|
|
637
|
+
// System mode: no validation applied, only client-provided projection/options.
|
|
638
|
+
const systemOptions =
|
|
639
|
+
projection || normalizedOptions
|
|
640
|
+
? {
|
|
641
|
+
...(normalizedOptions ?? {}),
|
|
642
|
+
...(projection ? { projection } : {})
|
|
643
|
+
}
|
|
644
|
+
: undefined
|
|
645
|
+
const response = await collection.findOne(resolvedQuery, systemOptions)
|
|
634
646
|
emitMongoEvent('findOne')
|
|
635
647
|
return response
|
|
636
648
|
} catch (error) {
|
|
@@ -1023,13 +1035,6 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1023
1035
|
projectionOrOptions,
|
|
1024
1036
|
options
|
|
1025
1037
|
)
|
|
1026
|
-
const resolvedOptions =
|
|
1027
|
-
projection || normalizedOptions
|
|
1028
|
-
? {
|
|
1029
|
-
...(normalizedOptions ?? {}),
|
|
1030
|
-
...(projection ? { projection } : {})
|
|
1031
|
-
}
|
|
1032
|
-
: undefined
|
|
1033
1038
|
if (!run_as_system) {
|
|
1034
1039
|
checkDenyOperation(
|
|
1035
1040
|
normalizedRules,
|
|
@@ -1039,6 +1044,17 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1039
1044
|
// Pre-query filtering based on access control rules
|
|
1040
1045
|
const formattedQuery = getFormattedQuery(filters, query, user)
|
|
1041
1046
|
const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {}
|
|
1047
|
+
// Rules-level projection has priority over client-provided projection.
|
|
1048
|
+
// The merged projection is passed natively to MongoDB.
|
|
1049
|
+
const rulesProjection = getFormattedProjection(filters, user)
|
|
1050
|
+
const finalProjection = mergeProjections(projection, rulesProjection)
|
|
1051
|
+
const resolvedOptions =
|
|
1052
|
+
finalProjection || normalizedOptions
|
|
1053
|
+
? {
|
|
1054
|
+
...(normalizedOptions ?? {}),
|
|
1055
|
+
...(finalProjection ? { projection: finalProjection } : {})
|
|
1056
|
+
}
|
|
1057
|
+
: undefined
|
|
1042
1058
|
// aggiunto filter per evitare questo errore: $and argument's entries must be objects
|
|
1043
1059
|
const cursor = collection.find(currentQuery, resolvedOptions)
|
|
1044
1060
|
const originalToArray = cursor.toArray.bind(cursor)
|
|
@@ -1084,8 +1100,15 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1084
1100
|
emitMongoEvent('find')
|
|
1085
1101
|
return cursor
|
|
1086
1102
|
}
|
|
1087
|
-
// System mode: return original unfiltered cursor
|
|
1088
|
-
const
|
|
1103
|
+
// System mode: return original unfiltered cursor (only client projection/options).
|
|
1104
|
+
const systemOptions =
|
|
1105
|
+
projection || normalizedOptions
|
|
1106
|
+
? {
|
|
1107
|
+
...(normalizedOptions ?? {}),
|
|
1108
|
+
...(projection ? { projection } : {})
|
|
1109
|
+
}
|
|
1110
|
+
: undefined
|
|
1111
|
+
const cursor = collection.find(query, systemOptions)
|
|
1089
1112
|
emitMongoEvent('find')
|
|
1090
1113
|
return cursor
|
|
1091
1114
|
} catch (error) {
|
|
@@ -1327,7 +1350,7 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1327
1350
|
formattedQuery,
|
|
1328
1351
|
pipeline
|
|
1329
1352
|
})
|
|
1330
|
-
const projection = getFormattedProjection(filters)
|
|
1353
|
+
const projection = getFormattedProjection(filters, user)
|
|
1331
1354
|
const hiddenFields = getHiddenFieldsFromRulesConfig(rulesConfig)
|
|
1332
1355
|
|
|
1333
1356
|
const sanitizedPipeline = applyAccessControlToPipeline(
|
|
@@ -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
|