@rebasepro/server-mongo 0.17.3 → 0.18.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.
Files changed (50) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +4 -0
  3. package/dist/MongoBootstrapper.d.ts +0 -1
  4. package/dist/auth/ensure-collections.d.ts +0 -1
  5. package/dist/auth/services.d.ts +0 -1
  6. package/dist/connection.d.ts +0 -1
  7. package/dist/db/MongoConditionBuilder.d.ts +0 -1
  8. package/dist/db/MongoDataService.d.ts +0 -1
  9. package/dist/db/securityRuleFilter.d.ts +0 -1
  10. package/dist/factory.d.ts +0 -1
  11. package/dist/history/ensure-history-collection.d.ts +0 -1
  12. package/dist/index.d.ts +0 -1
  13. package/dist/index.es.js +83 -79
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/schema/plan-schema-change.d.ts +0 -1
  16. package/dist/services/MongoDriver.d.ts +0 -1
  17. package/dist/services/MongoHistoryService.d.ts +0 -1
  18. package/dist/services/MongoRealtimeService.d.ts +0 -1
  19. package/dist/websocket.d.ts +0 -1
  20. package/package.json +28 -24
  21. package/dist/MongoBootstrapper.d.ts.map +0 -1
  22. package/dist/auth/ensure-collections.d.ts.map +0 -1
  23. package/dist/auth/services.d.ts.map +0 -1
  24. package/dist/connection.d.ts.map +0 -1
  25. package/dist/db/MongoConditionBuilder.d.ts.map +0 -1
  26. package/dist/db/MongoDataService.d.ts.map +0 -1
  27. package/dist/db/securityRuleFilter.d.ts.map +0 -1
  28. package/dist/factory.d.ts.map +0 -1
  29. package/dist/history/ensure-history-collection.d.ts.map +0 -1
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/schema/plan-schema-change.d.ts.map +0 -1
  32. package/dist/services/MongoDriver.d.ts.map +0 -1
  33. package/dist/services/MongoHistoryService.d.ts.map +0 -1
  34. package/dist/services/MongoRealtimeService.d.ts.map +0 -1
  35. package/dist/websocket.d.ts.map +0 -1
  36. package/src/MongoBootstrapper.ts +0 -204
  37. package/src/auth/ensure-collections.ts +0 -153
  38. package/src/auth/services.ts +0 -866
  39. package/src/connection.ts +0 -60
  40. package/src/db/MongoConditionBuilder.ts +0 -348
  41. package/src/db/MongoDataService.ts +0 -412
  42. package/src/db/securityRuleFilter.ts +0 -398
  43. package/src/factory.ts +0 -331
  44. package/src/history/ensure-history-collection.ts +0 -22
  45. package/src/index.ts +0 -25
  46. package/src/schema/plan-schema-change.ts +0 -159
  47. package/src/services/MongoDriver.ts +0 -950
  48. package/src/services/MongoHistoryService.ts +0 -186
  49. package/src/services/MongoRealtimeService.ts +0 -592
  50. package/src/websocket.ts +0 -387
@@ -1,398 +0,0 @@
1
- /**
2
- * Row security for MongoDB.
3
- *
4
- * MongoDB has no RLS, so this driver enforces `securityRules` in-process. That
5
- * makes the translation from a rule to a query the enforcement boundary, and it
6
- * has exactly one safe failure mode: refuse.
7
- *
8
- * Two properties this file exists to hold:
9
- *
10
- * 1. **One predicate, one implementation.** The rules are compiled through the
11
- * same {@link securityRuleToConditions} the Postgres DDL generator and the
12
- * admin UI's `checkOperation` use, so "what does this rule mean" is answered
13
- * in one place. The previous translator re-parsed the raw SQL itself and
14
- * recognised four shapes — a second, smaller parser that disagreed with the
15
- * first about the same rule.
16
- * 2. **Fail closed, out loud.** An expression with no MongoDB equivalent (raw
17
- * SQL, a membership subquery, a negated row predicate) used to become `{}` —
18
- * "match every document". It now raises {@link SECURITY_RULE_UNSUPPORTED},
19
- * the same shape the REST layer uses to refuse bulk writes this driver
20
- * cannot perform: a request that cannot be authorized is not served.
21
- */
22
-
23
- import { Document, Filter } from "mongodb";
24
- import {
25
- ANONYMOUS_USER_ID,
26
- CollectionConfig,
27
- PolicyExpression,
28
- PolicyOperand,
29
- SecurityOperation,
30
- SecurityRule,
31
- User,
32
- isAnonymousUid
33
- } from "@rebasepro/types";
34
- import { securityRuleToConditions } from "@rebasepro/common";
35
- import { ApiError } from "@rebasepro/server";
36
-
37
- /** Matches every document. */
38
- const MATCH_ALL: Filter<Document> = {};
39
-
40
- /**
41
- * Matches no document. A distinct object rather than a `false` sentinel so it
42
- * can be nested inside `$and`/`$or` like any other filter; identity is what
43
- * {@link isMatchNone} tests, so never mutate or copy it.
44
- */
45
- const MATCH_NONE: Filter<Document> = { _id: { $exists: false } };
46
-
47
- /** The expression has no MongoDB equivalent — the caller must refuse. */
48
- const UNTRANSLATABLE = "untranslatable" as const;
49
-
50
- type TranslationResult = Filter<Document> | typeof UNTRANSLATABLE;
51
-
52
- function isMatchAll(f: TranslationResult): boolean {
53
- return f !== UNTRANSLATABLE && f !== MATCH_NONE && Object.keys(f).length === 0;
54
- }
55
-
56
- function isMatchNone(f: TranslationResult): boolean {
57
- return f === MATCH_NONE;
58
- }
59
-
60
- /** The error code a caller sees when a rule cannot be honoured. */
61
- export const SECURITY_RULE_UNSUPPORTED = "SECURITY_RULE_UNSUPPORTED";
62
-
63
- /**
64
- * The refusal. Names the collection, the clause and the expression, because the
65
- * only useful thing an operator can do with this is rewrite that rule — or move
66
- * the collection to an engine that can enforce it.
67
- */
68
- export function securityRuleUnsupported(
69
- collectionSlug: string,
70
- clause: "using" | "withCheck",
71
- detail: string
72
- ): ApiError {
73
- return ApiError.internal(
74
- `This collection's data source (MongoDB) cannot enforce a security rule on "${collectionSlug}": ` +
75
- `the \`${clause}\` expression ${detail} has no MongoDB equivalent. The request was refused rather ` +
76
- "than served without row authorization. Express the rule with `access`, `ownerField`, `roles`, or a " +
77
- "structured `condition`/`check`, or move this collection to a Postgres data source.",
78
- SECURITY_RULE_UNSUPPORTED
79
- );
80
- }
81
-
82
- /** Describe an expression well enough for the refusal message to be actionable. */
83
- function describe(expr: PolicyExpression): string {
84
- switch (expr.kind) {
85
- case "raw":
86
- return `\`${expr.sql}\``;
87
- case "existsIn":
88
- return `a membership subquery over \`${expr.collection}\``;
89
- case "not":
90
- return "a negated row predicate";
91
- default:
92
- return `a \`${expr.kind}\` node`;
93
- }
94
- }
95
-
96
- /** The first node of an expression tree this driver cannot translate, if any. */
97
- function findUntranslatable(expr: PolicyExpression, hasRow: boolean): PolicyExpression | undefined {
98
- switch (expr.kind) {
99
- case "and":
100
- case "or": {
101
- for (const operand of expr.operands) {
102
- const found = findUntranslatable(operand, hasRow);
103
- if (found) return found;
104
- }
105
- return undefined;
106
- }
107
- case "not":
108
- // Only decidable without the row when the operand is: negating a
109
- // column predicate in MongoDB (`$nor`) also matches documents that
110
- // lack the column, which SQL's three-valued logic would exclude.
111
- return hasRow ? findUntranslatable(expr.operand, hasRow) : (referencesField(expr.operand) ? expr : undefined);
112
- case "compare":
113
- return operandUntranslatable(expr.left) || operandUntranslatable(expr.right) ? expr : undefined;
114
- case "existsIn":
115
- case "raw":
116
- return expr;
117
- default:
118
- return undefined;
119
- }
120
- }
121
-
122
- function operandUntranslatable(operand: PolicyOperand): boolean {
123
- return operand.kind === "outerField";
124
- }
125
-
126
- function referencesField(expr: PolicyExpression): boolean {
127
- switch (expr.kind) {
128
- case "and":
129
- case "or":
130
- return expr.operands.some(referencesField);
131
- case "not":
132
- return referencesField(expr.operand);
133
- case "compare":
134
- return expr.left.kind === "field" || expr.right.kind === "field" ||
135
- expr.left.kind === "outerField" || expr.right.kind === "outerField";
136
- default:
137
- return false;
138
- }
139
- }
140
-
141
- /** The acting user, as the policy model sees them. */
142
- interface PolicyUserContext {
143
- uid: string;
144
- roles: string[];
145
- }
146
-
147
- function userContext(user: User | undefined): PolicyUserContext {
148
- // The sentinel, not an empty string: `rebase.uid()` is never NULL for a
149
- // request that came from a client, and an `ownerField` rule compared
150
- // against `undefined` would become `{ owner: undefined }` — which MongoDB
151
- // reads as `{ owner: null }` and matches every document that has no owner.
152
- return {
153
- uid: user?.uid || ANONYMOUS_USER_ID,
154
- roles: user?.roles ?? []
155
- };
156
- }
157
-
158
- const COMPARE_TO_MONGO = {
159
- eq: "$eq",
160
- neq: "$ne",
161
- lt: "$lt",
162
- lte: "$lte",
163
- gt: "$gt",
164
- gte: "$gte"
165
- } as const;
166
-
167
- const INVERTED_COMPARE = {
168
- eq: "eq",
169
- neq: "neq",
170
- lt: "gt",
171
- lte: "gte",
172
- gt: "lt",
173
- gte: "lte"
174
- } as const;
175
-
176
- type ResolvedOperand =
177
- | { kind: "field"; name: string }
178
- | { kind: "value"; value: unknown }
179
- | { kind: "unknown" };
180
-
181
- function resolveOperand(operand: PolicyOperand, ctx: PolicyUserContext): ResolvedOperand {
182
- switch (operand.kind) {
183
- case "literal":
184
- return { kind: "value", value: operand.value };
185
- case "authUid":
186
- return { kind: "value", value: ctx.uid };
187
- case "authRoles":
188
- return { kind: "value", value: ctx.roles };
189
- case "field":
190
- return { kind: "field", name: operand.name };
191
- case "outerField":
192
- return { kind: "unknown" };
193
- }
194
- }
195
-
196
- /**
197
- * Translate one {@link PolicyExpression} into a MongoDB filter, or
198
- * {@link UNTRANSLATABLE}.
199
- *
200
- * The JavaScript twin of `evaluatePolicy`, one level up: where that decides a
201
- * single row, this narrows a query. `"unknown"` there and `UNTRANSLATABLE` here
202
- * are the same condition, and both are resolved fail-closed by their callers.
203
- */
204
- export function policyToMongoFilter(expr: PolicyExpression, user: User | undefined): TranslationResult {
205
- const ctx = userContext(user);
206
-
207
- switch (expr.kind) {
208
- case "true":
209
- return MATCH_ALL;
210
- case "false":
211
- return MATCH_NONE;
212
- case "and": {
213
- const parts = expr.operands.map(o => policyToMongoFilter(o, user));
214
- // Kleene AND: a `false` operand settles the conjunction even when a
215
- // sibling is untranslatable, which is what keeps a role-scoped raw
216
- // rule from refusing requests it does not even apply to.
217
- if (parts.some(isMatchNone)) return MATCH_NONE;
218
- if (parts.some(p => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
219
- const clauses = (parts as Filter<Document>[]).filter(p => !isMatchAll(p));
220
- if (clauses.length === 0) return MATCH_ALL;
221
- if (clauses.length === 1) return clauses[0];
222
- return { $and: clauses } as Filter<Document>;
223
- }
224
- case "or": {
225
- const parts = expr.operands.map(o => policyToMongoFilter(o, user));
226
- if (parts.some(isMatchAll)) return MATCH_ALL;
227
- if (parts.some(p => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
228
- const clauses = (parts as Filter<Document>[]).filter(p => !isMatchNone(p));
229
- if (clauses.length === 0) return MATCH_NONE;
230
- if (clauses.length === 1) return clauses[0];
231
- return { $or: clauses } as Filter<Document>;
232
- }
233
- case "not": {
234
- const inner = policyToMongoFilter(expr.operand, user);
235
- // Constant-folded only. See `findUntranslatable` for why a negated
236
- // column predicate is refused instead of becoming `$nor`.
237
- if (isMatchAll(inner)) return MATCH_NONE;
238
- if (isMatchNone(inner)) return MATCH_ALL;
239
- return UNTRANSLATABLE;
240
- }
241
- case "compare": {
242
- const left = resolveOperand(expr.left, ctx);
243
- const right = resolveOperand(expr.right, ctx);
244
- if (left.kind === "unknown" || right.kind === "unknown") return UNTRANSLATABLE;
245
-
246
- if (left.kind === "field" && right.kind === "value") {
247
- return { [left.name]: { [COMPARE_TO_MONGO[expr.op]]: right.value } } as Filter<Document>;
248
- }
249
- if (left.kind === "value" && right.kind === "field") {
250
- return { [right.name]: { [COMPARE_TO_MONGO[INVERTED_COMPARE[expr.op]]]: left.value } } as Filter<Document>;
251
- }
252
- if (left.kind === "field" && right.kind === "field") {
253
- return { $expr: { [COMPARE_TO_MONGO[expr.op]]: [`$${left.name}`, `$${right.name}`] } } as Filter<Document>;
254
- }
255
- // Both sides are known values — the comparison is a constant.
256
- if (left.kind === "value" && right.kind === "value") {
257
- return compareValues(expr.op, left.value, right.value);
258
- }
259
- return UNTRANSLATABLE;
260
- }
261
- case "rolesOverlap":
262
- return expr.roles.some(r => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
263
- case "rolesContain":
264
- return expr.roles.every(r => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
265
- case "authenticated":
266
- return !isAnonymousUid(ctx.uid) ? MATCH_ALL : MATCH_NONE;
267
- case "serverContext":
268
- // A scoped driver is always acting for a user, never the server
269
- // context — the same answer `evaluatePolicy` gives.
270
- return MATCH_NONE;
271
- case "existsIn":
272
- case "raw":
273
- return UNTRANSLATABLE;
274
- }
275
- }
276
-
277
- function compareValues(op: keyof typeof COMPARE_TO_MONGO, a: unknown, b: unknown): TranslationResult {
278
- if (op === "eq") return a === b ? MATCH_ALL : MATCH_NONE;
279
- if (op === "neq") return a !== b ? MATCH_ALL : MATCH_NONE;
280
- if ((typeof a === "string" && typeof b === "string") || (typeof a === "number" && typeof b === "number")) {
281
- const decided = op === "lt" ? a < b : op === "lte" ? a <= b : op === "gt" ? a > b : a >= b;
282
- return decided ? MATCH_ALL : MATCH_NONE;
283
- }
284
- return UNTRANSLATABLE;
285
- }
286
-
287
- /** The rules that apply to `targetOperation`, mirroring `checkOperation`. */
288
- function applicableRules(collection: CollectionConfig | undefined, targetOperation: SecurityOperation): SecurityRule[] {
289
- const rules = collection?.securityRules;
290
- if (!rules || rules.length === 0) return [];
291
- return rules.filter((rule: SecurityRule) => {
292
- const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
293
- return ops.includes(targetOperation) || ops.includes("all");
294
- });
295
- }
296
-
297
- /** Which clause of a rule constrains `targetOperation` — Postgres's own split. */
298
- function clauseFor(targetOperation: SecurityOperation): "using" | "withCheck" {
299
- return targetOperation === "insert" ? "withCheck" : "using";
300
- }
301
-
302
- /**
303
- * Refuse up front when this collection's rules cannot be enforced for
304
- * `targetOperation`.
305
- *
306
- * The row-in-hand paths (`fetchOne`, `save`, `delete`) resolve an undecidable
307
- * rule through `checkOperation`'s `onUnknown: "deny"`, which is safe but
308
- * indistinguishable from a plain "you may not do that". Calling this first
309
- * turns the same condition into the refusal an operator can act on.
310
- */
311
- export function assertSecurityRulesEnforceable(
312
- collection: CollectionConfig | undefined,
313
- targetOperation: SecurityOperation
314
- ): void {
315
- for (const rule of applicableRules(collection, targetOperation)) {
316
- const conditions = securityRuleToConditions(rule);
317
- const clauses: ("using" | "withCheck")[] = targetOperation === "insert"
318
- ? ["withCheck"]
319
- : targetOperation === "update" ? ["using", "withCheck"] : ["using"];
320
- for (const clause of clauses) {
321
- const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
322
- if (!expr) continue;
323
- // `hasRow: true` — these callers evaluate against a fetched row, so
324
- // only the nodes no JavaScript evaluator can decide are refused.
325
- const offending = findUntranslatable(expr, true);
326
- if (offending) {
327
- throw securityRuleUnsupported(collection?.slug ?? "unknown", clause, describe(offending));
328
- }
329
- }
330
- }
331
- }
332
-
333
- /**
334
- * Build the MongoDB filter that narrows a query to the rows `user` may see
335
- * under `collection`'s security rules.
336
- *
337
- * Returns `null` when no row can qualify (the caller answers with an empty
338
- * result), `{}` when the rules impose no narrowing, and throws
339
- * {@link SECURITY_RULE_UNSUPPORTED} when a rule cannot be translated.
340
- */
341
- export function buildMongoFilterFromSecurityRules<M extends Record<string, any>>(
342
- collection: CollectionConfig<M> | undefined,
343
- user: User | undefined,
344
- targetOperation: SecurityOperation
345
- ): Filter<Document> | null {
346
- const rules = applicableRules(collection as CollectionConfig | undefined, targetOperation);
347
- if (!collection?.securityRules || collection.securityRules.length === 0) {
348
- return MATCH_ALL;
349
- }
350
- // Rules exist but none covers this operation — Postgres denies, so do we.
351
- if (rules.length === 0) return null;
352
-
353
- const clause = clauseFor(targetOperation);
354
- const permissive: Filter<Document>[] = [];
355
- const restrictive: Filter<Document>[] = [];
356
-
357
- for (const rule of rules) {
358
- const conditions = securityRuleToConditions(rule);
359
- const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
360
- // A null clause denies, matching Postgres's `USING (false)`.
361
- const filter = expr === null ? MATCH_NONE : policyToMongoFilter(expr, user);
362
- if (filter === UNTRANSLATABLE) {
363
- const offending = expr === null ? undefined : findUntranslatable(expr, false);
364
- throw securityRuleUnsupported(
365
- collection.slug,
366
- clause,
367
- offending ? describe(offending) : "this rule"
368
- );
369
- }
370
- if ((rule.mode || "permissive") === "restrictive") {
371
- restrictive.push(filter);
372
- } else {
373
- permissive.push(filter);
374
- }
375
- }
376
-
377
- // No permissive rule can grant → nothing is visible, exactly as
378
- // `checkOperation` returns false when `hasPermissive` is false.
379
- if (permissive.length === 0) return null;
380
-
381
- const parts: Filter<Document>[] = [];
382
- if (!permissive.some(isMatchAll)) {
383
- // A permissive rule that matches nothing contributes nothing to the
384
- // union; if that is all of them, nothing is visible.
385
- const granting = permissive.filter(p => !isMatchNone(p));
386
- if (granting.length === 0) return null;
387
- parts.push(granting.length === 1 ? granting[0] : ({ $or: granting } as Filter<Document>));
388
- }
389
-
390
- for (const rf of restrictive) {
391
- if (isMatchNone(rf)) return null;
392
- if (!isMatchAll(rf)) parts.push(rf);
393
- }
394
-
395
- if (parts.length === 0) return MATCH_ALL;
396
- if (parts.length === 1) return parts[0];
397
- return { $and: parts } as Filter<Document>;
398
- }