@rebasepro/server-mongo 0.13.0 → 0.13.1-canary.g06dbe5b
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/MongoBootstrapper.d.ts.map +1 -1
- package/dist/auth/ensure-collections.d.ts.map +1 -1
- package/dist/db/MongoConditionBuilder.d.ts +28 -1
- package/dist/db/MongoConditionBuilder.d.ts.map +1 -1
- package/dist/db/MongoDataService.d.ts +19 -1
- package/dist/db/MongoDataService.d.ts.map +1 -1
- package/dist/db/securityRuleFilter.d.ts +65 -0
- package/dist/db/securityRuleFilter.d.ts.map +1 -0
- package/dist/ensure-collections-Bl12bSwO.js +130 -0
- package/dist/ensure-collections-Bl12bSwO.js.map +1 -0
- package/dist/factory.d.ts +11 -1
- package/dist/factory.d.ts.map +1 -1
- package/dist/index.es.js +487 -249
- package/dist/index.es.js.map +1 -1
- package/dist/services/MongoDriver.d.ts +37 -5
- package/dist/services/MongoDriver.d.ts.map +1 -1
- package/dist/services/MongoRealtimeService.d.ts +23 -14
- package/dist/services/MongoRealtimeService.d.ts.map +1 -1
- package/dist/{websocket-74DDOdK0.js → websocket-Bs54-gR-.js} +32 -11
- package/dist/websocket-Bs54-gR-.js.map +1 -0
- package/dist/websocket.d.ts +2 -2
- package/dist/websocket.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/MongoBootstrapper.ts +10 -2
- package/src/auth/ensure-collections.ts +61 -13
- package/src/db/MongoConditionBuilder.ts +112 -40
- package/src/db/MongoDataService.ts +51 -14
- package/src/db/securityRuleFilter.ts +398 -0
- package/src/factory.ts +17 -4
- package/src/services/MongoDriver.ts +101 -262
- package/src/services/MongoRealtimeService.ts +124 -66
- package/src/websocket.ts +66 -15
- package/dist/ensure-collections-Bkx_O5CQ.js +0 -94
- package/dist/ensure-collections-Bkx_O5CQ.js.map +0 -1
- package/dist/websocket-74DDOdK0.js.map +0 -1
|
@@ -22,16 +22,17 @@ import {
|
|
|
22
22
|
RebaseClient,
|
|
23
23
|
RebaseData,
|
|
24
24
|
RebaseSdkData,
|
|
25
|
-
|
|
25
|
+
SecurityOperation
|
|
26
26
|
} from "@rebasepro/types";
|
|
27
27
|
import { MongoDataService } from "../db/MongoDataService";
|
|
28
28
|
import { MongoRealtimeService } from "./MongoRealtimeService";
|
|
29
29
|
import { MongoHistoryService } from "./MongoHistoryService";
|
|
30
|
-
import { buildPropertyCallbacks, updateDateAutoValues, buildSdkData, checkOperation } from "@rebasepro/common";
|
|
30
|
+
import { buildPropertyCallbacks, updateDateAutoValues, buildSdkData, checkOperation, PolicyClauses } from "@rebasepro/common";
|
|
31
31
|
import { mergeDeep } from "@rebasepro/utils";
|
|
32
32
|
import { Filter, Document } from "mongodb";
|
|
33
33
|
import { ApiError } from "@rebasepro/server";
|
|
34
34
|
import { MongoConditionBuilder } from "../db/MongoConditionBuilder";
|
|
35
|
+
import { assertSecurityRulesEnforceable, buildMongoFilterFromSecurityRules } from "../db/securityRuleFilter";
|
|
35
36
|
import { logger } from "@rebasepro/server";
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -109,23 +110,17 @@ propertyCallbacks: undefined };
|
|
|
109
110
|
/**
|
|
110
111
|
* Fetch a collection of rows
|
|
111
112
|
*/
|
|
112
|
-
async fetchCollection<M extends Record<string, any>>(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
|
|
113
|
+
async fetchCollection<M extends Record<string, any>>(
|
|
114
|
+
props: FetchCollectionProps<M>
|
|
115
|
+
): Promise<Record<string, unknown>[]> {
|
|
116
|
+
// Forwarded whole rather than re-listed. The hand-written list here
|
|
117
|
+
// named eight of the eleven fields `FetchCollectionProps` declares, so
|
|
118
|
+
// `logical` and `offset` were accepted by every type-checked boundary
|
|
119
|
+
// above and then dropped — an `or(...)` query ran unfiltered and
|
|
120
|
+
// `?offset=` served page one.
|
|
121
|
+
const { path, collection, ...query } = props;
|
|
122
122
|
const rows = await this.dataService.fetchCollection<M>(path, {
|
|
123
|
-
|
|
124
|
-
limit,
|
|
125
|
-
startAfter,
|
|
126
|
-
orderBy,
|
|
127
|
-
order,
|
|
128
|
-
searchString,
|
|
123
|
+
...query,
|
|
129
124
|
collection: collection as CollectionConfig
|
|
130
125
|
});
|
|
131
126
|
|
|
@@ -173,7 +168,13 @@ propertyCallbacks: undefined };
|
|
|
173
168
|
}
|
|
174
169
|
|
|
175
170
|
/**
|
|
176
|
-
* Listen to collection changes
|
|
171
|
+
* Listen to collection changes.
|
|
172
|
+
*
|
|
173
|
+
* `authContext` is not part of `ListenCollectionProps`; it is supplied by
|
|
174
|
+
* {@link AuthenticatedMongoDriver}, which is the only caller that has one.
|
|
175
|
+
* It has to travel *into* the subscription config, because that config is
|
|
176
|
+
* what every re-fetch reads — the wrapper used to stamp the field on the
|
|
177
|
+
* `Subscription` object instead, and nothing has ever read that one.
|
|
177
178
|
*/
|
|
178
179
|
listenCollection<M extends Record<string, any>>({
|
|
179
180
|
path,
|
|
@@ -186,7 +187,7 @@ propertyCallbacks: undefined };
|
|
|
186
187
|
order,
|
|
187
188
|
onUpdate,
|
|
188
189
|
onError
|
|
189
|
-
}: ListenCollectionProps<M
|
|
190
|
+
}: ListenCollectionProps<M>, authContext?: { uid: string; roles: string[] }): () => void {
|
|
190
191
|
const subscriptionId = this.generateSubscriptionId();
|
|
191
192
|
|
|
192
193
|
const callback = (rows: Record<string, unknown>[]) => {
|
|
@@ -210,7 +211,8 @@ propertyCallbacks: undefined };
|
|
|
210
211
|
order,
|
|
211
212
|
limit,
|
|
212
213
|
startAfter,
|
|
213
|
-
searchString
|
|
214
|
+
searchString,
|
|
215
|
+
authContext
|
|
214
216
|
},
|
|
215
217
|
callback
|
|
216
218
|
);
|
|
@@ -282,7 +284,7 @@ propertyCallbacks: undefined };
|
|
|
282
284
|
collection,
|
|
283
285
|
onUpdate,
|
|
284
286
|
onError
|
|
285
|
-
}: ListenOneProps<M
|
|
287
|
+
}: ListenOneProps<M>, authContext?: { uid: string; roles: string[] }): () => void {
|
|
286
288
|
const subscriptionId = this.generateSubscriptionId();
|
|
287
289
|
|
|
288
290
|
const callback = (row: Record<string, unknown> | null) => {
|
|
@@ -301,7 +303,8 @@ propertyCallbacks: undefined };
|
|
|
301
303
|
{
|
|
302
304
|
clientId: "driver",
|
|
303
305
|
path,
|
|
304
|
-
id
|
|
306
|
+
id,
|
|
307
|
+
authContext
|
|
305
308
|
},
|
|
306
309
|
callback
|
|
307
310
|
);
|
|
@@ -656,9 +659,18 @@ propertyCallbacks: undefined };
|
|
|
656
659
|
async count<M extends Record<string, any>>({
|
|
657
660
|
path,
|
|
658
661
|
collection,
|
|
659
|
-
filter
|
|
662
|
+
filter,
|
|
663
|
+
logical,
|
|
664
|
+
searchString
|
|
660
665
|
}: FetchCollectionProps<M>): Promise<number> {
|
|
661
|
-
|
|
666
|
+
// The same narrowing the listing gets, or the total describes a
|
|
667
|
+
// different query than the rows it is reported beside.
|
|
668
|
+
return this.dataService.count<M>(path, {
|
|
669
|
+
filter,
|
|
670
|
+
logical,
|
|
671
|
+
searchString,
|
|
672
|
+
collection: collection as CollectionConfig
|
|
673
|
+
});
|
|
662
674
|
}
|
|
663
675
|
|
|
664
676
|
/**
|
|
@@ -719,8 +731,13 @@ export class AuthenticatedMongoDriver implements DataDriver {
|
|
|
719
731
|
return [];
|
|
720
732
|
}
|
|
721
733
|
|
|
734
|
+
// `logical` belongs in this query, not in the props spread below: the
|
|
735
|
+
// repository reads `rawQuery ?? buildQuery(...)`, so a `logical` that
|
|
736
|
+
// travelled only in the spread was never consulted — and a dropped
|
|
737
|
+
// `or(...)` group does not fail, it widens.
|
|
722
738
|
const userQuery = MongoConditionBuilder.buildQuery({
|
|
723
739
|
filter: props.filter,
|
|
740
|
+
logical: props.logical,
|
|
724
741
|
searchString: props.searchString,
|
|
725
742
|
properties: resolvedCollection?.properties
|
|
726
743
|
});
|
|
@@ -780,82 +797,103 @@ export class AuthenticatedMongoDriver implements DataDriver {
|
|
|
780
797
|
}
|
|
781
798
|
|
|
782
799
|
listenCollection<M extends Record<string, any>>(props: ListenCollectionProps<M>): () => void {
|
|
783
|
-
|
|
784
|
-
|
|
800
|
+
// Handed to the subscription rather than stamped on it afterwards: the
|
|
801
|
+
// config is what every re-fetch reads, and the stamp also landed after
|
|
802
|
+
// the initial fetch had already been dispatched unfiltered.
|
|
803
|
+
return this.delegate.listenCollection(props, this.authContext());
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The acting user, in the shape the realtime subscriptions carry. */
|
|
807
|
+
private authContext(): { uid: string; roles: string[] } {
|
|
808
|
+
return { uid: this.user.uid,
|
|
785
809
|
roles: this.user.roles ?? [] };
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Evaluate the collection's rules for one row, fail-closed.
|
|
814
|
+
*
|
|
815
|
+
* A path with no resolvable collection has no declared rules — the same
|
|
816
|
+
* answer `buildMongoFilterFromSecurityRules` gives a listing on such a path,
|
|
817
|
+
* so the two never disagree about whether this engine has row security.
|
|
818
|
+
*/
|
|
819
|
+
private authorize(
|
|
820
|
+
collection: CollectionConfig | undefined,
|
|
821
|
+
entity: Entity,
|
|
822
|
+
operation: SecurityOperation,
|
|
823
|
+
clauses?: PolicyClauses
|
|
824
|
+
): boolean {
|
|
825
|
+
if (!collection) return true;
|
|
826
|
+
return checkOperation(collection, { user: this.user }, entity, operation, { onUnknown: "deny",
|
|
827
|
+
clauses });
|
|
793
828
|
}
|
|
794
829
|
|
|
795
830
|
async fetchOne<M extends Record<string, any>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
|
|
796
831
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
|
|
832
|
+
assertSecurityRulesEnforceable(resolvedCollection, "select");
|
|
797
833
|
const row = await this.delegate.fetchOne(props);
|
|
798
|
-
if (row) {
|
|
799
|
-
|
|
800
|
-
if (!authorized) {
|
|
801
|
-
return undefined;
|
|
802
|
-
}
|
|
834
|
+
if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) {
|
|
835
|
+
return undefined;
|
|
803
836
|
}
|
|
804
837
|
return row;
|
|
805
838
|
}
|
|
806
839
|
|
|
807
840
|
listenOne<M extends Record<string, any>>(props: ListenOneProps<M>): () => void {
|
|
808
|
-
|
|
809
|
-
const authContext = { uid: this.user.uid,
|
|
810
|
-
roles: this.user.roles ?? [] };
|
|
811
|
-
const subscriptions = this.delegate.getRealtimeService().getSubscriptions();
|
|
812
|
-
const lastEntry = Array.from(subscriptions.entries()).pop();
|
|
813
|
-
const lastSub = lastEntry?.[1];
|
|
814
|
-
if (lastSub && lastSub.config.clientId === "driver") {
|
|
815
|
-
lastSub.authContext = authContext;
|
|
816
|
-
}
|
|
817
|
-
return unsubscribe;
|
|
841
|
+
return this.delegate.listenOne(props, this.authContext());
|
|
818
842
|
}
|
|
819
843
|
|
|
844
|
+
/**
|
|
845
|
+
* Save, with both halves of the rule checked *before* the write.
|
|
846
|
+
*
|
|
847
|
+
* There is no transaction here, so a check that runs after
|
|
848
|
+
* `delegate.save` cannot undo anything: the document is written, history is
|
|
849
|
+
* recorded and subscribers have been notified by then, and a 403 at that
|
|
850
|
+
* point only misleads the caller about what happened. Postgres evaluates
|
|
851
|
+
* `WITH CHECK` inside the transaction; the closest this driver can get is to
|
|
852
|
+
* evaluate it against the row as it *will* be, and refuse before writing.
|
|
853
|
+
*/
|
|
820
854
|
async save<M extends Record<string, any>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
|
|
821
855
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
|
|
822
856
|
|
|
823
857
|
if (props.status === "existing" && props.id) {
|
|
858
|
+
assertSecurityRulesEnforceable(resolvedCollection, "update");
|
|
824
859
|
const existing = await this.delegate.fetchOne({ path: props.path,
|
|
825
860
|
id: props.id,
|
|
826
861
|
collection: resolvedCollection });
|
|
827
|
-
|
|
862
|
+
// USING against the stored row, WITH CHECK against the row that
|
|
863
|
+
// will replace it — the split Postgres makes, and the reason
|
|
864
|
+
// `clauses` exists on `checkOperation`.
|
|
865
|
+
const projected = rowToEntityForCheck({ ...existing,
|
|
866
|
+
...props.values,
|
|
867
|
+
id: props.id }, props.path);
|
|
868
|
+
if (!existing ||
|
|
869
|
+
!this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.path), "update", "using") ||
|
|
870
|
+
!this.authorize(resolvedCollection, projected, "update", "withCheck")) {
|
|
828
871
|
throw ApiError.forbidden("Forbidden");
|
|
829
872
|
}
|
|
830
873
|
} else {
|
|
874
|
+
assertSecurityRulesEnforceable(resolvedCollection, "insert");
|
|
831
875
|
const tempEntity = { id: props.id || "new",
|
|
832
876
|
path: props.path,
|
|
833
877
|
values: props.values } as Entity;
|
|
834
|
-
if (!
|
|
878
|
+
if (!this.authorize(resolvedCollection, tempEntity, "insert")) {
|
|
835
879
|
throw ApiError.forbidden("Forbidden");
|
|
836
880
|
}
|
|
837
881
|
}
|
|
838
882
|
|
|
839
|
-
|
|
883
|
+
return this.delegate.save({
|
|
840
884
|
...props,
|
|
841
885
|
collection: resolvedCollection
|
|
842
886
|
});
|
|
843
|
-
|
|
844
|
-
// After save / withCheck rules verification
|
|
845
|
-
if (!checkOperation(resolvedCollection as CollectionConfig, { user: this.user }, rowToEntityForCheck(saved, props.path), props.status === "existing" ? "update" : "insert", { onUnknown: "deny" })) {
|
|
846
|
-
throw ApiError.forbidden("Forbidden");
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
return saved;
|
|
850
887
|
}
|
|
851
888
|
|
|
852
889
|
async delete<M extends Record<string, any>>(props: DeleteProps<M>): Promise<void> {
|
|
853
890
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
|
|
891
|
+
assertSecurityRulesEnforceable(resolvedCollection, "delete");
|
|
854
892
|
|
|
855
893
|
const existing = await this.delegate.fetchOne({ path: props.row.path,
|
|
856
894
|
id: props.row.id,
|
|
857
895
|
collection: resolvedCollection });
|
|
858
|
-
if (!existing || !
|
|
896
|
+
if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) {
|
|
859
897
|
throw ApiError.forbidden("Forbidden");
|
|
860
898
|
}
|
|
861
899
|
|
|
@@ -883,8 +921,11 @@ collection: resolvedCollection });
|
|
|
883
921
|
return 0;
|
|
884
922
|
}
|
|
885
923
|
|
|
924
|
+
// Narrowed by exactly what the listing is narrowed by — `logical`
|
|
925
|
+
// included — or the total describes a different query than the rows.
|
|
886
926
|
const userQuery = MongoConditionBuilder.buildQuery({
|
|
887
927
|
filter: props.filter,
|
|
928
|
+
logical: props.logical,
|
|
888
929
|
searchString: props.searchString,
|
|
889
930
|
properties: resolvedCollection?.properties
|
|
890
931
|
});
|
|
@@ -916,205 +957,3 @@ function rowToEntityForCheck(row: Record<string, unknown>, path: string): Entity
|
|
|
916
957
|
values: row
|
|
917
958
|
};
|
|
918
959
|
}
|
|
919
|
-
|
|
920
|
-
function getMongoFilterForSQL(sqlString: string, user: User): Filter<Document> | null {
|
|
921
|
-
let cleanedSQL = sqlString.trim();
|
|
922
|
-
while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
|
|
923
|
-
let openCount = 0;
|
|
924
|
-
let isEnclosing = true;
|
|
925
|
-
for (let i = 0; i < cleanedSQL.length - 1; i++) {
|
|
926
|
-
if (cleanedSQL[i] === "(") openCount++;
|
|
927
|
-
else if (cleanedSQL[i] === ")") openCount--;
|
|
928
|
-
if (openCount === 0) {
|
|
929
|
-
isEnclosing = false;
|
|
930
|
-
break;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
if (isEnclosing) {
|
|
934
|
-
cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
|
|
935
|
-
} else {
|
|
936
|
-
break;
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
const splitByTopLevel = (str: string, delimiter: string) => {
|
|
941
|
-
const parts: string[] = [];
|
|
942
|
-
let current = "";
|
|
943
|
-
let openCount = 0;
|
|
944
|
-
let i = 0;
|
|
945
|
-
while (i < str.length) {
|
|
946
|
-
if (str[i] === "(") openCount++;
|
|
947
|
-
else if (str[i] === ")") openCount--;
|
|
948
|
-
|
|
949
|
-
if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
|
|
950
|
-
parts.push(current);
|
|
951
|
-
current = "";
|
|
952
|
-
i += delimiter.length;
|
|
953
|
-
} else {
|
|
954
|
-
current += str[i];
|
|
955
|
-
i++;
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
parts.push(current);
|
|
959
|
-
return parts;
|
|
960
|
-
};
|
|
961
|
-
|
|
962
|
-
const orParts = splitByTopLevel(cleanedSQL, " OR ");
|
|
963
|
-
if (orParts.length > 1) {
|
|
964
|
-
const subFilters = orParts.map(part => getMongoFilterForSQL(part, user)).filter(f => f !== null) as Filter<Document>[];
|
|
965
|
-
if (subFilters.length === 0) return null;
|
|
966
|
-
if (subFilters.length === 1) return subFilters[0];
|
|
967
|
-
return { $or: subFilters } as Filter<Document>;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
const andParts = splitByTopLevel(cleanedSQL, " AND ");
|
|
971
|
-
if (andParts.length > 1) {
|
|
972
|
-
const subFilters = andParts.map(part => getMongoFilterForSQL(part, user)).filter(f => f !== null) as Filter<Document>[];
|
|
973
|
-
if (subFilters.length === 0) return null;
|
|
974
|
-
if (subFilters.length === 1) return subFilters[0];
|
|
975
|
-
return { $and: subFilters } as Filter<Document>;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
|
|
979
|
-
if (roleIntersectMatch && roleIntersectMatch[1]) {
|
|
980
|
-
const requiredRoles = roleIntersectMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
|
|
981
|
-
const userRoles = user.roles || [];
|
|
982
|
-
const matches = requiredRoles.some(r => userRoles.includes(r));
|
|
983
|
-
return matches ? {} : { _id: { $exists: false } };
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
|
|
987
|
-
if (roleContainMatch && roleContainMatch[1]) {
|
|
988
|
-
const requiredRoles = roleContainMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
|
|
989
|
-
const userRoles = user.roles || [];
|
|
990
|
-
const matches = requiredRoles.every(r => userRoles.includes(r));
|
|
991
|
-
return matches ? {} : { _id: { $exists: false } };
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
const pattern1 = new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
|
|
995
|
-
const pattern2 = new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
|
|
996
|
-
|
|
997
|
-
const match1 = cleanedSQL.match(pattern1);
|
|
998
|
-
if (match1 && match1[1]) {
|
|
999
|
-
return { [match1[1]]: user.uid };
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
const match2 = cleanedSQL.match(pattern2);
|
|
1003
|
-
if (match2 && match2[1]) {
|
|
1004
|
-
return { [match2[1]]: user.uid };
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
|
|
1008
|
-
if (simpleEqualityMatch) {
|
|
1009
|
-
const field = simpleEqualityMatch[1];
|
|
1010
|
-
const operator = simpleEqualityMatch[2];
|
|
1011
|
-
const value = simpleEqualityMatch[3];
|
|
1012
|
-
if (operator === "=") return { [field]: value };
|
|
1013
|
-
if (operator === "!=") return { [field]: { $ne: value } };
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
return {};
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
function getMongoFilterForRule(rule: SecurityRule, user: User): Filter<Document> | null {
|
|
1020
|
-
if (rule.access === "public") return {};
|
|
1021
|
-
|
|
1022
|
-
const filters: Filter<Document>[] = [];
|
|
1023
|
-
|
|
1024
|
-
if (rule.ownerField) {
|
|
1025
|
-
filters.push({ [rule.ownerField]: user.uid });
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
if (rule.using) {
|
|
1029
|
-
const f = getMongoFilterForSQL(rule.using, user);
|
|
1030
|
-
if (f) filters.push(f);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
if (rule.withCheck) {
|
|
1034
|
-
const f = getMongoFilterForSQL(rule.withCheck, user);
|
|
1035
|
-
if (f) filters.push(f);
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
if (filters.length === 0) return {};
|
|
1039
|
-
if (filters.length === 1) return filters[0];
|
|
1040
|
-
return { $and: filters } as Filter<Document>;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
function buildMongoFilterFromSecurityRules<M extends Record<string, any>>(
|
|
1044
|
-
collection: CollectionConfig<M> | undefined,
|
|
1045
|
-
user: User,
|
|
1046
|
-
targetOperation: "select" | "insert" | "update" | "delete"
|
|
1047
|
-
): Filter<Document> | null {
|
|
1048
|
-
if (!collection || !collection.securityRules || collection.securityRules.length === 0) {
|
|
1049
|
-
return {};
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
const applicableRules = collection.securityRules.filter((r: SecurityRule) =>
|
|
1053
|
-
r.operation === targetOperation ||
|
|
1054
|
-
r.operation === "all" ||
|
|
1055
|
-
r.operations?.includes(targetOperation) ||
|
|
1056
|
-
r.operations?.includes("all")
|
|
1057
|
-
);
|
|
1058
|
-
|
|
1059
|
-
if (applicableRules.length === 0) {
|
|
1060
|
-
return null;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
const userRoleIds = user.roles ?? [];
|
|
1064
|
-
const userRoles = [...userRoleIds, "public"];
|
|
1065
|
-
const roleApplicableRules = applicableRules.filter((rule: SecurityRule) => {
|
|
1066
|
-
if (!rule.roles || rule.roles.length === 0) return true;
|
|
1067
|
-
return rule.roles.some((r: string) => userRoles.includes(r));
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
if (roleApplicableRules.length === 0) {
|
|
1071
|
-
return null;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
const permissiveFilters: Filter<Document>[] = [];
|
|
1075
|
-
const restrictiveFilters: Filter<Document>[] = [];
|
|
1076
|
-
|
|
1077
|
-
for (const rule of roleApplicableRules) {
|
|
1078
|
-
const mode = rule.mode || "permissive";
|
|
1079
|
-
const filter = getMongoFilterForRule(rule, user);
|
|
1080
|
-
if (filter === null) {
|
|
1081
|
-
if (mode === "restrictive") {
|
|
1082
|
-
return null;
|
|
1083
|
-
}
|
|
1084
|
-
continue;
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
if (mode === "restrictive") {
|
|
1088
|
-
restrictiveFilters.push(filter);
|
|
1089
|
-
} else {
|
|
1090
|
-
permissiveFilters.push(filter);
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const finalAnds: Filter<Document>[] = [];
|
|
1095
|
-
|
|
1096
|
-
if (permissiveFilters.length > 0) {
|
|
1097
|
-
const hasAlwaysTruePermissive = permissiveFilters.some(f => Object.keys(f).length === 0);
|
|
1098
|
-
if (!hasAlwaysTruePermissive) {
|
|
1099
|
-
if (permissiveFilters.length === 1) {
|
|
1100
|
-
finalAnds.push(permissiveFilters[0]);
|
|
1101
|
-
} else {
|
|
1102
|
-
finalAnds.push({ $or: permissiveFilters } as Filter<Document>);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
} else {
|
|
1106
|
-
return null;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
if (restrictiveFilters.length > 0) {
|
|
1110
|
-
for (const rf of restrictiveFilters) {
|
|
1111
|
-
if (Object.keys(rf).length > 0) {
|
|
1112
|
-
finalAnds.push(rf);
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
if (finalAnds.length === 0) return {};
|
|
1118
|
-
if (finalAnds.length === 1) return finalAnds[0];
|
|
1119
|
-
return { $and: finalAnds } as Filter<Document>;
|
|
1120
|
-
}
|