@zenstackhq/runtime 1.0.0-beta.2 → 1.0.0-beta.20

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 (75) hide show
  1. package/browser/index.d.mts +13 -0
  2. package/browser/index.d.ts +13 -0
  3. package/browser/index.js +70 -0
  4. package/browser/index.js.map +1 -0
  5. package/browser/index.mjs +32 -0
  6. package/browser/index.mjs.map +1 -0
  7. package/constants.d.ts +59 -2
  8. package/constants.js +60 -2
  9. package/constants.js.map +1 -1
  10. package/enhancements/enhance.d.ts +18 -0
  11. package/enhancements/enhance.js +42 -0
  12. package/enhancements/enhance.js.map +1 -0
  13. package/enhancements/index.d.ts +5 -0
  14. package/enhancements/index.js +5 -0
  15. package/enhancements/index.js.map +1 -1
  16. package/enhancements/model-data-visitor.d.ts +16 -0
  17. package/enhancements/model-data-visitor.js +41 -0
  18. package/enhancements/model-data-visitor.js.map +1 -0
  19. package/enhancements/model-meta.d.ts +6 -1
  20. package/enhancements/model-meta.js +23 -2
  21. package/enhancements/model-meta.js.map +1 -1
  22. package/enhancements/nested-write-vistor.d.ts +21 -16
  23. package/enhancements/nested-write-vistor.js +73 -34
  24. package/enhancements/nested-write-vistor.js.map +1 -1
  25. package/enhancements/omit.d.ts +1 -1
  26. package/enhancements/policy/handler.d.ts +25 -15
  27. package/enhancements/policy/handler.js +771 -133
  28. package/enhancements/policy/handler.js.map +1 -1
  29. package/enhancements/policy/index.d.ts +5 -1
  30. package/enhancements/policy/index.js +53 -3
  31. package/enhancements/policy/index.js.map +1 -1
  32. package/enhancements/policy/logger.js +1 -1
  33. package/enhancements/policy/logger.js.map +1 -1
  34. package/enhancements/policy/policy-utils.d.ts +102 -46
  35. package/enhancements/policy/policy-utils.js +683 -550
  36. package/enhancements/policy/policy-utils.js.map +1 -1
  37. package/enhancements/preset.d.ts +3 -8
  38. package/enhancements/preset.js +2 -4
  39. package/enhancements/preset.js.map +1 -1
  40. package/enhancements/proxy.d.ts +3 -1
  41. package/enhancements/proxy.js +23 -12
  42. package/enhancements/proxy.js.map +1 -1
  43. package/enhancements/types.d.ts +25 -8
  44. package/enhancements/types.js +1 -0
  45. package/enhancements/types.js.map +1 -1
  46. package/enhancements/utils.d.ts +4 -0
  47. package/enhancements/utils.js +59 -8
  48. package/enhancements/utils.js.map +1 -1
  49. package/enhancements/where-visitor.d.ts +33 -0
  50. package/enhancements/where-visitor.js +87 -0
  51. package/enhancements/where-visitor.js.map +1 -0
  52. package/error.js +9 -3
  53. package/error.js.map +1 -1
  54. package/index.d.ts +3 -2
  55. package/index.js +3 -2
  56. package/index.js.map +1 -1
  57. package/package.json +33 -9
  58. package/types.d.ts +11 -2
  59. package/types.js +2 -0
  60. package/types.js.map +1 -1
  61. package/version.d.ts +5 -0
  62. package/version.js +34 -1
  63. package/version.js.map +1 -1
  64. package/zod/index.d.ts +2 -0
  65. package/zod/index.js +4 -0
  66. package/zod/input.d.ts +1 -0
  67. package/zod/input.js +8 -0
  68. package/zod/models.d.ts +1 -0
  69. package/zod/models.js +8 -0
  70. package/serialization-utils.d.ts +0 -1
  71. package/serialization-utils.js +0 -22
  72. package/serialization-utils.js.map +0 -1
  73. package/zod.d.ts +0 -10
  74. package/zod.js +0 -17
  75. package/zod.js.map +0 -1
@@ -14,130 +14,236 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  };
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.PolicyUtil = void 0;
17
- const cuid2_1 = require("@paralleldrive/cuid2");
18
17
  const deepcopy_1 = __importDefault(require("deepcopy"));
19
18
  const lower_case_first_1 = require("lower-case-first");
20
- const pluralize_1 = __importDefault(require("pluralize"));
19
+ const upper_case_first_1 = require("upper-case-first");
21
20
  const zod_validation_error_1 = require("zod-validation-error");
22
21
  const constants_1 = require("../../constants");
23
22
  const version_1 = require("../../version");
24
23
  const model_meta_1 = require("../model-meta");
25
- const nested_write_vistor_1 = require("../nested-write-vistor");
26
24
  const utils_1 = require("../utils");
27
25
  const logger_1 = require("./logger");
28
26
  /**
29
27
  * Access policy enforcement utilities
30
28
  */
31
29
  class PolicyUtil {
32
- constructor(db, modelMeta, policy, user, logPrismaQuery) {
30
+ constructor(db, modelMeta, policy, zodSchemas, user, shouldLogQuery = false) {
33
31
  this.db = db;
34
32
  this.modelMeta = modelMeta;
35
33
  this.policy = policy;
34
+ this.zodSchemas = zodSchemas;
36
35
  this.user = user;
37
- this.logPrismaQuery = logPrismaQuery;
36
+ this.shouldLogQuery = shouldLogQuery;
38
37
  this.logger = new logger_1.Logger(db);
39
38
  }
39
+ //#region Logical operators
40
40
  /**
41
41
  * Creates a conjunction of a list of query conditions.
42
42
  */
43
43
  and(...conditions) {
44
- if (conditions.includes(false)) {
45
- // always false
46
- return { [constants_1.GUARD_FIELD_NAME]: false };
47
- }
48
- const filtered = conditions.filter((c) => typeof c === 'object' && !!c && Object.keys(c).length > 0);
49
- if (filtered.length === 0) {
50
- return undefined;
51
- }
52
- else if (filtered.length === 1) {
53
- return filtered[0];
54
- }
55
- else {
56
- return { AND: filtered };
57
- }
44
+ return this.reduce({ AND: conditions });
58
45
  }
59
46
  /**
60
47
  * Creates a disjunction of a list of query conditions.
61
48
  */
62
49
  or(...conditions) {
63
- if (conditions.includes(true)) {
64
- // always true
65
- return { [constants_1.GUARD_FIELD_NAME]: true };
50
+ return this.reduce({ OR: conditions });
51
+ }
52
+ /**
53
+ * Creates a negation of a query condition.
54
+ */
55
+ not(condition) {
56
+ if (condition === undefined) {
57
+ return this.makeTrue();
66
58
  }
67
- const filtered = conditions.filter((c) => typeof c === 'object' && !!c);
68
- if (filtered.length === 0) {
69
- return undefined;
59
+ else if (typeof condition === 'boolean') {
60
+ return this.reduce(!condition);
70
61
  }
71
- else if (filtered.length === 1) {
72
- return filtered[0];
62
+ else {
63
+ return this.reduce({ NOT: condition });
64
+ }
65
+ }
66
+ // Static True/False conditions
67
+ // https://www.prisma.io/docs/concepts/components/prisma-client/null-and-undefined#the-effect-of-null-and-undefined-on-conditionals
68
+ isTrue(condition) {
69
+ if (condition === null || condition === undefined) {
70
+ return false;
73
71
  }
74
72
  else {
75
- return { OR: filtered };
73
+ return ((typeof condition === 'object' && Object.keys(condition).length === 0) ||
74
+ ('AND' in condition && Array.isArray(condition.AND) && condition.AND.length === 0));
76
75
  }
77
76
  }
78
- /**
79
- * Creates a negation of a query condition.
80
- */
81
- not(condition) {
82
- if (typeof condition === 'boolean') {
83
- return !condition;
77
+ isFalse(condition) {
78
+ if (condition === null || condition === undefined) {
79
+ return false;
84
80
  }
85
81
  else {
86
- return { NOT: condition };
82
+ return 'OR' in condition && Array.isArray(condition.OR) && condition.OR.length === 0;
83
+ }
84
+ }
85
+ makeTrue() {
86
+ return { AND: [] };
87
+ }
88
+ makeFalse() {
89
+ return { OR: [] };
90
+ }
91
+ reduce(condition) {
92
+ if (condition === true || condition === undefined) {
93
+ return this.makeTrue();
94
+ }
95
+ if (condition === false) {
96
+ return this.makeFalse();
97
+ }
98
+ if ('AND' in condition && Array.isArray(condition.AND)) {
99
+ const children = condition.AND.map((c) => this.reduce(c)).filter((c) => c !== undefined && !this.isTrue(c));
100
+ if (children.length === 0) {
101
+ return this.makeTrue();
102
+ }
103
+ else if (children.some((c) => this.isFalse(c))) {
104
+ return this.makeFalse();
105
+ }
106
+ else if (children.length === 1) {
107
+ return children[0];
108
+ }
109
+ else {
110
+ return { AND: children };
111
+ }
112
+ }
113
+ if ('OR' in condition && Array.isArray(condition.OR)) {
114
+ const children = condition.OR.map((c) => this.reduce(c)).filter((c) => c !== undefined && !this.isFalse(c));
115
+ if (children.length === 0) {
116
+ return this.makeFalse();
117
+ }
118
+ else if (children.some((c) => this.isTrue(c))) {
119
+ return this.makeTrue();
120
+ }
121
+ else if (children.length === 1) {
122
+ return children[0];
123
+ }
124
+ else {
125
+ return { OR: children };
126
+ }
127
+ }
128
+ if ('NOT' in condition && condition.NOT !== null && typeof condition.NOT === 'object') {
129
+ const child = this.reduce(condition.NOT);
130
+ if (this.isTrue(child)) {
131
+ return this.makeFalse();
132
+ }
133
+ else if (this.isFalse(child)) {
134
+ return this.makeTrue();
135
+ }
136
+ else {
137
+ return { NOT: child };
138
+ }
87
139
  }
140
+ return condition;
88
141
  }
142
+ //#endregion
143
+ //# Auth guard
89
144
  /**
90
145
  * Gets pregenerated authorization guard object for a given model and operation.
91
146
  *
92
147
  * @returns true if operation is unconditionally allowed, false if unconditionally denied,
93
148
  * otherwise returns a guard object
94
149
  */
95
- getAuthGuard(model, operation, preValue) {
96
- return __awaiter(this, void 0, void 0, function* () {
97
- const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
98
- if (!guard) {
99
- throw this.unknownError(`unable to load policy guard for ${model}`);
100
- }
101
- const provider = guard[operation];
102
- if (typeof provider === 'boolean') {
103
- return provider;
104
- }
105
- if (!provider) {
106
- throw this.unknownError(`zenstack: unable to load authorization guard for ${model}`);
107
- }
108
- return provider({ user: this.user, preValue });
109
- });
150
+ getAuthGuard(db, model, operation, preValue) {
151
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
152
+ if (!guard) {
153
+ throw this.unknownError(`unable to load policy guard for ${model}`);
154
+ }
155
+ const provider = guard[operation];
156
+ if (typeof provider === 'boolean') {
157
+ return this.reduce(provider);
158
+ }
159
+ if (!provider) {
160
+ throw this.unknownError(`zenstack: unable to load authorization guard for ${model}`);
161
+ }
162
+ const r = provider({ user: this.user, preValue }, db);
163
+ return this.reduce(r);
110
164
  }
111
- getPreValueSelect(model) {
112
- return __awaiter(this, void 0, void 0, function* () {
113
- const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
114
- if (!guard) {
115
- throw this.unknownError(`unable to load policy guard for ${model}`);
116
- }
117
- return guard.preValueSelect;
118
- });
165
+ /**
166
+ * Get field-level auth guard
167
+ */
168
+ getFieldUpdateAuthGuard(db, model, field) {
169
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
170
+ if (!guard) {
171
+ throw this.unknownError(`unable to load policy guard for ${model}`);
172
+ }
173
+ const provider = guard[`${constants_1.FIELD_LEVEL_UPDATE_GUARD_PREFIX}${field}`];
174
+ if (typeof provider === 'boolean') {
175
+ return this.reduce(provider);
176
+ }
177
+ if (!provider) {
178
+ return this.makeTrue();
179
+ }
180
+ const r = provider({ user: this.user }, db);
181
+ return this.reduce(r);
119
182
  }
120
- getModelSchema(model) {
121
- return __awaiter(this, void 0, void 0, function* () {
122
- return this.policy.schema[(0, lower_case_first_1.lowerCaseFirst)(model)];
123
- });
183
+ /**
184
+ * Checks if the given model has a policy guard for the given operation.
185
+ */
186
+ hasAuthGuard(model, operation) {
187
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
188
+ if (!guard) {
189
+ return false;
190
+ }
191
+ const provider = guard[operation];
192
+ return typeof provider !== 'boolean' || provider !== true;
193
+ }
194
+ /**
195
+ * Checks model creation policy based on static analysis to the input args.
196
+ *
197
+ * @returns boolean if static analysis is enough to determine the result, undefined if not
198
+ */
199
+ checkInputGuard(model, args, operation) {
200
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
201
+ if (!guard) {
202
+ return undefined;
203
+ }
204
+ const provider = guard[`${operation}_input`];
205
+ if (typeof provider === 'boolean') {
206
+ return provider;
207
+ }
208
+ if (!provider) {
209
+ return undefined;
210
+ }
211
+ return provider(args, { user: this.user });
124
212
  }
125
213
  /**
126
214
  * Injects model auth guard as where clause.
127
215
  */
128
- injectAuthGuard(args, model, operation) {
216
+ injectAuthGuard(db, args, model, operation) {
129
217
  return __awaiter(this, void 0, void 0, function* () {
218
+ let guard = this.getAuthGuard(db, model, operation);
219
+ if (this.isFalse(guard)) {
220
+ args.where = this.makeFalse();
221
+ return false;
222
+ }
223
+ if (operation === 'update' && args) {
224
+ // merge field-level policy guards
225
+ const fieldUpdateGuard = this.getFieldUpdateGuards(db, model, args);
226
+ if (fieldUpdateGuard.rejectedByField) {
227
+ // rejected
228
+ args.where = this.makeFalse();
229
+ return false;
230
+ }
231
+ else if (fieldUpdateGuard.guard) {
232
+ // merge
233
+ guard = this.and(guard, fieldUpdateGuard.guard);
234
+ }
235
+ }
130
236
  if (args.where) {
131
237
  // inject into relation fields:
132
238
  // to-many: some/none/every
133
239
  // to-one: direct-conditions/is/isNot
134
- yield this.injectGuardForFields(model, args.where, operation);
240
+ yield this.injectGuardForRelationFields(db, model, args.where, operation);
135
241
  }
136
- const guard = yield this.getAuthGuard(model, operation);
137
242
  args.where = this.and(args.where, guard);
243
+ return true;
138
244
  });
139
245
  }
140
- injectGuardForFields(model, payload, operation) {
246
+ injectGuardForRelationFields(db, model, payload, operation) {
141
247
  return __awaiter(this, void 0, void 0, function* () {
142
248
  for (const [field, subPayload] of Object.entries(payload)) {
143
249
  if (!subPayload) {
@@ -148,24 +254,24 @@ class PolicyUtil {
148
254
  continue;
149
255
  }
150
256
  if (fieldInfo.isArray) {
151
- yield this.injectGuardForToManyField(fieldInfo, subPayload, operation);
257
+ yield this.injectGuardForToManyField(db, fieldInfo, subPayload, operation);
152
258
  }
153
259
  else {
154
- yield this.injectGuardForToOneField(fieldInfo, subPayload, operation);
260
+ yield this.injectGuardForToOneField(db, fieldInfo, subPayload, operation);
155
261
  }
156
262
  }
157
263
  });
158
264
  }
159
- injectGuardForToManyField(fieldInfo, payload, operation) {
265
+ injectGuardForToManyField(db, fieldInfo, payload, operation) {
160
266
  return __awaiter(this, void 0, void 0, function* () {
161
- const guard = yield this.getAuthGuard(fieldInfo.type, operation);
267
+ const guard = this.getAuthGuard(db, fieldInfo.type, operation);
162
268
  if (payload.some) {
163
- yield this.injectGuardForFields(fieldInfo.type, payload.some, operation);
269
+ yield this.injectGuardForRelationFields(db, fieldInfo.type, payload.some, operation);
164
270
  // turn "some" into: { some: { AND: [guard, payload.some] } }
165
271
  payload.some = this.and(payload.some, guard);
166
272
  }
167
273
  if (payload.none) {
168
- yield this.injectGuardForFields(fieldInfo.type, payload.none, operation);
274
+ yield this.injectGuardForRelationFields(db, fieldInfo.type, payload.none, operation);
169
275
  // turn none into: { none: { AND: [guard, payload.none] } }
170
276
  payload.none = this.and(payload.none, guard);
171
277
  }
@@ -173,7 +279,7 @@ class PolicyUtil {
173
279
  typeof payload.every === 'object' &&
174
280
  // ignore empty every clause
175
281
  Object.keys(payload.every).length > 0) {
176
- yield this.injectGuardForFields(fieldInfo.type, payload.every, operation);
282
+ yield this.injectGuardForRelationFields(db, fieldInfo.type, payload.every, operation);
177
283
  // turn "every" into: { none: { AND: [guard, { NOT: payload.every }] } }
178
284
  if (!payload.none) {
179
285
  payload.none = {};
@@ -183,24 +289,24 @@ class PolicyUtil {
183
289
  }
184
290
  });
185
291
  }
186
- injectGuardForToOneField(fieldInfo, payload, operation) {
292
+ injectGuardForToOneField(db, fieldInfo, payload, operation) {
187
293
  return __awaiter(this, void 0, void 0, function* () {
188
- const guard = yield this.getAuthGuard(fieldInfo.type, operation);
294
+ const guard = this.getAuthGuard(db, fieldInfo.type, operation);
189
295
  if (payload.is || payload.isNot) {
190
296
  if (payload.is) {
191
- yield this.injectGuardForFields(fieldInfo.type, payload.is, operation);
297
+ yield this.injectGuardForRelationFields(db, fieldInfo.type, payload.is, operation);
192
298
  // turn "is" into: { is: { AND: [ originalIs, guard ] }
193
299
  payload.is = this.and(payload.is, guard);
194
300
  }
195
301
  if (payload.isNot) {
196
- yield this.injectGuardForFields(fieldInfo.type, payload.isNot, operation);
302
+ yield this.injectGuardForRelationFields(db, fieldInfo.type, payload.isNot, operation);
197
303
  // turn "isNot" into: { isNot: { AND: [ originalIsNot, { NOT: guard } ] } }
198
304
  payload.isNot = this.and(payload.isNot, this.not(guard));
199
305
  delete payload.isNot;
200
306
  }
201
307
  }
202
308
  else {
203
- yield this.injectGuardForFields(fieldInfo.type, payload, operation);
309
+ yield this.injectGuardForRelationFields(db, fieldInfo.type, payload, operation);
204
310
  // turn direct conditions into: { is: { AND: [ originalConditions, guard ] } }
205
311
  const combined = this.and((0, deepcopy_1.default)(payload), guard);
206
312
  Object.keys(payload).forEach((key) => delete payload[key]);
@@ -209,436 +315,405 @@ class PolicyUtil {
209
315
  });
210
316
  }
211
317
  /**
212
- * Read model entities w.r.t the given query args. The result list
213
- * are guaranteed to fully satisfy 'read' policy rules recursively.
214
- *
215
- * For to-many relations involved, items not satisfying policy are
216
- * silently trimmed. For to-one relation, if relation data fails policy
217
- * an error is thrown.
318
+ * Injects auth guard for read operations.
218
319
  */
219
- readWithCheck(model, args) {
320
+ injectForRead(db, model, args) {
220
321
  return __awaiter(this, void 0, void 0, function* () {
221
- args = this.clone(args);
322
+ const injected = {};
323
+ if (!(yield this.injectAuthGuard(db, injected, model, 'read'))) {
324
+ return false;
325
+ }
222
326
  if (args.where) {
223
- // query args will be used with findMany, so we need to
224
- // translate unique constraint filters into a flat filter
225
- // e.g.: { a_b: { a: '1', b: '1' } } => { a: '1', b: '1' }
226
- yield this.flattenGeneratedUniqueField(model, args.where);
227
- }
228
- yield this.injectAuthGuard(args, model, 'read');
229
- // recursively inject read guard conditions into the query args
230
- yield this.injectNestedReadConditions(model, args);
231
- if (this.shouldLogQuery) {
232
- this.logger.info(`[withPolicy] \`findMany\`:\n${(0, utils_1.formatObject)(args)}`);
327
+ // inject into relation fields:
328
+ // to-many: some/none/every
329
+ // to-one: direct-conditions/is/isNot
330
+ yield this.injectGuardForRelationFields(db, model, args.where, 'read');
233
331
  }
234
- const result = yield this.db[model].findMany(args);
235
- yield this.postProcessForRead(result, model, args, 'read');
236
- return result;
332
+ if (injected.where && Object.keys(injected.where).length > 0 && !this.isTrue(injected.where)) {
333
+ if (!args.where) {
334
+ args.where = injected.where;
335
+ }
336
+ else {
337
+ this.mergeWhereClause(args.where, injected.where);
338
+ }
339
+ }
340
+ // recursively inject read guard conditions into nested select, include, and _count
341
+ const hoistedConditions = yield this.injectNestedReadConditions(db, model, args);
342
+ // the injection process may generate conditions that need to be hoisted to the toplevel,
343
+ // if so, merge it with the existing where
344
+ if (hoistedConditions.length > 0) {
345
+ if (!args.where) {
346
+ args.where = this.and(...hoistedConditions);
347
+ }
348
+ else {
349
+ this.mergeWhereClause(args.where, this.and(...hoistedConditions));
350
+ }
351
+ }
352
+ return true;
237
353
  });
238
354
  }
239
355
  // flatten unique constraint filters
240
356
  flattenGeneratedUniqueField(model, args) {
241
357
  var _a;
358
+ // e.g.: { a_b: { a: '1', b: '1' } } => { a: '1', b: '1' }
359
+ const uniqueConstraints = (_a = this.modelMeta.uniqueConstraints) === null || _a === void 0 ? void 0 : _a[(0, lower_case_first_1.lowerCaseFirst)(model)];
360
+ if (uniqueConstraints && Object.keys(uniqueConstraints).length > 0) {
361
+ for (const [field, value] of Object.entries(args)) {
362
+ if (uniqueConstraints[field] &&
363
+ uniqueConstraints[field].fields.length > 1 &&
364
+ typeof value === 'object') {
365
+ // multi-field unique constraint, flatten it
366
+ delete args[field];
367
+ for (const [f, v] of Object.entries(value)) {
368
+ args[f] = v;
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+ /**
375
+ * Gets unique constraints for the given model.
376
+ */
377
+ getUniqueConstraints(model) {
378
+ var _a, _b;
379
+ return (_b = (_a = this.modelMeta.uniqueConstraints) === null || _a === void 0 ? void 0 : _a[(0, lower_case_first_1.lowerCaseFirst)(model)]) !== null && _b !== void 0 ? _b : {};
380
+ }
381
+ /**
382
+ * Builds a reversed query for the given nested path.
383
+ */
384
+ buildReversedQuery(context) {
242
385
  return __awaiter(this, void 0, void 0, function* () {
243
- // e.g.: { a_b: { a: '1', b: '1' } } => { a: '1', b: '1' }
244
- const uniqueConstraints = (_a = this.modelMeta.uniqueConstraints) === null || _a === void 0 ? void 0 : _a[(0, lower_case_first_1.lowerCaseFirst)(model)];
245
- let flattened = false;
246
- if (uniqueConstraints && Object.keys(uniqueConstraints).length > 0) {
247
- for (const [field, value] of Object.entries(args)) {
248
- if (uniqueConstraints[field] && typeof value === 'object') {
249
- for (const [f, v] of Object.entries(value)) {
250
- args[f] = v;
386
+ let result, currQuery;
387
+ let currField;
388
+ for (let i = context.nestingPath.length - 1; i >= 0; i--) {
389
+ const { field, model, where } = context.nestingPath[i];
390
+ // never modify the original where because it's shared in the structure
391
+ const visitWhere = Object.assign({}, where);
392
+ if (model && where) {
393
+ // make sure composite unique condition is flattened
394
+ this.flattenGeneratedUniqueField(model, visitWhere);
395
+ }
396
+ if (!result) {
397
+ // first segment (bottom), just use its where clause
398
+ result = currQuery = Object.assign({}, visitWhere);
399
+ currField = field;
400
+ }
401
+ else {
402
+ if (!currField) {
403
+ throw this.unknownError(`missing field in nested path`);
404
+ }
405
+ if (!currField.backLink) {
406
+ throw this.unknownError(`field ${currField.type}.${currField.name} doesn't have a backLink`);
407
+ }
408
+ const backLinkField = this.getModelField(currField.type, currField.backLink);
409
+ if (backLinkField === null || backLinkField === void 0 ? void 0 : backLinkField.isArray) {
410
+ // many-side of relationship, wrap with "some" query
411
+ currQuery[currField.backLink] = { some: Object.assign({}, visitWhere) };
412
+ }
413
+ else {
414
+ if (where && backLinkField.isRelationOwner && backLinkField.foreignKeyMapping) {
415
+ for (const [r, fk] of Object.entries(backLinkField.foreignKeyMapping)) {
416
+ currQuery[fk] = visitWhere[r];
417
+ }
418
+ if (i > 0) {
419
+ currQuery[currField.backLink] = {};
420
+ }
421
+ }
422
+ else {
423
+ currQuery[currField.backLink] = Object.assign({}, visitWhere);
251
424
  }
252
- delete args[field];
253
- flattened = true;
254
425
  }
426
+ currQuery = currQuery[currField.backLink];
427
+ currField = field;
255
428
  }
256
429
  }
257
- if (flattened) {
258
- // DEBUG
259
- // this.logger.info(`Filter flattened: ${JSON.stringify(args)}`);
260
- }
430
+ return result;
261
431
  });
262
432
  }
263
- injectNestedReadConditions(model, args) {
264
- var _a, _b;
433
+ injectNestedReadConditions(db, model, args) {
434
+ var _a;
265
435
  return __awaiter(this, void 0, void 0, function* () {
266
436
  const injectTarget = (_a = args.select) !== null && _a !== void 0 ? _a : args.include;
267
437
  if (!injectTarget) {
268
- return;
438
+ return [];
439
+ }
440
+ if (injectTarget._count !== undefined) {
441
+ // _count needs to respect read policies of related models
442
+ if (injectTarget._count === true) {
443
+ // include count for all relations, expand to all fields
444
+ // so that we can inject guard conditions for each of them
445
+ injectTarget._count = { select: {} };
446
+ const modelFields = (0, model_meta_1.getFields)(this.modelMeta, model);
447
+ if (modelFields) {
448
+ for (const [k, v] of Object.entries(modelFields)) {
449
+ if (v.isDataModel && v.isArray) {
450
+ // create an entry for to-many relation
451
+ injectTarget._count.select[k] = {};
452
+ }
453
+ }
454
+ }
455
+ }
456
+ // inject conditions for each relation
457
+ for (const field of Object.keys(injectTarget._count.select)) {
458
+ if (typeof injectTarget._count.select[field] !== 'object') {
459
+ injectTarget._count.select[field] = {};
460
+ }
461
+ const fieldInfo = (0, model_meta_1.resolveField)(this.modelMeta, model, field);
462
+ if (!fieldInfo) {
463
+ continue;
464
+ }
465
+ // inject into the "where" clause inside select
466
+ yield this.injectAuthGuard(db, injectTarget._count.select[field], fieldInfo.type, 'read');
467
+ }
269
468
  }
270
- const idFields = this.getIdFields(model);
469
+ // collect filter conditions that should be hoisted to the toplevel
470
+ const hoistedConditions = [];
271
471
  for (const field of (0, utils_1.getModelFields)(injectTarget)) {
272
472
  const fieldInfo = (0, model_meta_1.resolveField)(this.modelMeta, model, field);
273
473
  if (!fieldInfo || !fieldInfo.isDataModel) {
274
474
  // only care about relation fields
275
475
  continue;
276
476
  }
277
- if (fieldInfo.isArray) {
477
+ let hoisted;
478
+ if (fieldInfo.isArray ||
479
+ // Injecting where at include/select level for nullable to-one relation is supported since Prisma 4.8.0
480
+ // https://github.com/prisma/prisma/discussions/20350
481
+ fieldInfo.isOptional) {
278
482
  if (typeof injectTarget[field] !== 'object') {
279
483
  injectTarget[field] = {};
280
484
  }
281
- // inject extra condition for to-many relation
282
- yield this.injectAuthGuard(injectTarget[field], fieldInfo.type, 'read');
485
+ // inject extra condition for to-many or nullable to-one relation
486
+ yield this.injectAuthGuard(db, injectTarget[field], fieldInfo.type, 'read');
487
+ // recurse
488
+ const subHoisted = yield this.injectNestedReadConditions(db, fieldInfo.type, injectTarget[field]);
489
+ if (subHoisted.length > 0) {
490
+ // we can convert it to a where at this level
491
+ injectTarget[field].where = this.and(injectTarget[field].where, ...subHoisted);
492
+ }
283
493
  }
284
494
  else {
285
- // there's no way of injecting condition for to-one relation, so if there's
286
- // "select" clause we make sure 'id' fields are selected and check them against
287
- // query result; nothing needs to be done for "include" clause because all
288
- // fields are already selected
289
- if ((_b = injectTarget[field]) === null || _b === void 0 ? void 0 : _b.select) {
290
- for (const idField of idFields) {
291
- if (injectTarget[field].select[idField.name] !== true) {
292
- injectTarget[field].select[idField.name] = true;
293
- }
294
- }
495
+ // hoist non-nullable to-one filter to the parent level
496
+ hoisted = this.getAuthGuard(db, fieldInfo.type, 'read');
497
+ // recurse
498
+ const subHoisted = yield this.injectNestedReadConditions(db, fieldInfo.type, injectTarget[field]);
499
+ if (subHoisted.length > 0) {
500
+ hoisted = this.and(hoisted, ...subHoisted);
295
501
  }
296
502
  }
297
- // recurse
298
- yield this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]);
503
+ if (hoisted && !this.isTrue(hoisted)) {
504
+ hoistedConditions.push({ [field]: hoisted });
505
+ }
299
506
  }
507
+ return hoistedConditions;
300
508
  });
301
509
  }
302
510
  /**
303
- * Post processing checks for read model entities. Validates to-one relations
304
- * (which can't be trimmed at query time) and removes fields that should be
305
- * omitted.
511
+ * Given a model and a unique filter, checks the operation is allowed by policies and field validations.
512
+ * Rejects with an error if not allowed.
306
513
  */
307
- postProcessForRead(data, model, args, operation) {
308
- var _a;
514
+ checkPolicyForUnique(model, uniqueFilter, operation, db, args, preValue) {
309
515
  return __awaiter(this, void 0, void 0, function* () {
310
- for (const entityData of (0, utils_1.enumerate)(data)) {
311
- if (typeof entityData !== 'object' || !entityData) {
312
- continue;
313
- }
314
- // strip auxiliary fields
315
- for (const auxField of constants_1.AUXILIARY_FIELDS) {
316
- if (auxField in entityData) {
317
- delete entityData[auxField];
318
- }
516
+ let guard = this.getAuthGuard(db, model, operation, preValue);
517
+ if (this.isFalse(guard)) {
518
+ throw this.deniedByPolicy(model, operation, `entity ${(0, utils_1.formatObject)(uniqueFilter)} failed policy check`);
519
+ }
520
+ if (operation === 'update' && args) {
521
+ // merge field-level policy guards
522
+ const fieldUpdateGuard = this.getFieldUpdateGuards(db, model, args);
523
+ if (fieldUpdateGuard.rejectedByField) {
524
+ // rejected
525
+ throw this.deniedByPolicy(model, 'update', `entity ${(0, utils_1.formatObject)(uniqueFilter)} failed update policy check for field "${fieldUpdateGuard.rejectedByField}"`);
319
526
  }
320
- const injectTarget = (_a = args.select) !== null && _a !== void 0 ? _a : args.include;
321
- if (!injectTarget) {
322
- continue;
527
+ else if (fieldUpdateGuard.guard) {
528
+ // merge
529
+ guard = this.and(guard, fieldUpdateGuard.guard);
323
530
  }
324
- // recurse into nested entities
325
- for (const field of Object.keys(injectTarget)) {
326
- const fieldData = entityData[field];
327
- if (typeof fieldData !== 'object' || !fieldData) {
328
- continue;
329
- }
330
- const fieldInfo = (0, model_meta_1.resolveField)(this.modelMeta, model, field);
331
- if (fieldInfo) {
332
- if (fieldInfo.isDataModel && !fieldInfo.isArray) {
333
- // to-one relation data cannot be trimmed by injected guards, we have to
334
- // post-check them
335
- const ids = this.getEntityIds(fieldInfo.type, fieldData);
336
- if (Object.keys(ids).length !== 0) {
337
- // if (this.logger.enabled('info')) {
338
- // this.logger.info(
339
- // `Validating read of to-one relation: ${fieldInfo.type}#${formatObject(ids)}`
340
- // );
341
- // }
342
- yield this.checkPolicyForFilter(fieldInfo.type, ids, operation, this.db);
343
- }
344
- }
345
- // recurse
346
- yield this.postProcessForRead(fieldData, fieldInfo.type, injectTarget[field], operation);
531
+ }
532
+ // Zod schema is to be checked for "create" and "postUpdate"
533
+ const schema = ['create', 'postUpdate'].includes(operation) ? this.getZodSchema(model) : undefined;
534
+ if (this.isTrue(guard) && !schema) {
535
+ // unconditionally allowed
536
+ return;
537
+ }
538
+ const select = schema
539
+ ? // need to validate against schema, need to fetch all fields
540
+ undefined
541
+ : // only fetch id fields
542
+ this.makeIdSelection(model);
543
+ let where = this.clone(uniqueFilter);
544
+ // query args may have be of combined-id form, need to flatten it to call findFirst
545
+ this.flattenGeneratedUniqueField(model, where);
546
+ // query with policy guard
547
+ where = this.and(where, guard);
548
+ const query = { select, where };
549
+ if (this.shouldLogQuery) {
550
+ this.logger.info(`[policy] checking ${model} for ${operation}, \`findFirst\`:\n${(0, utils_1.formatObject)(query)}`);
551
+ }
552
+ const result = yield db[model].findFirst(query);
553
+ if (!result) {
554
+ throw this.deniedByPolicy(model, operation, `entity ${(0, utils_1.formatObject)(uniqueFilter)} failed policy check`);
555
+ }
556
+ if (schema) {
557
+ // TODO: push down schema check to the database
558
+ const parseResult = schema.safeParse(result);
559
+ if (!parseResult.success) {
560
+ const error = (0, zod_validation_error_1.fromZodError)(parseResult.error);
561
+ if (this.logger.enabled('info')) {
562
+ this.logger.info(`entity ${model} failed validation for operation ${operation}: ${error}`);
347
563
  }
564
+ throw this.deniedByPolicy(model, operation, `entities ${JSON.stringify(uniqueFilter)} failed validation: [${error}]`, constants_1.CrudFailureReason.DATA_VALIDATION_VIOLATION);
348
565
  }
349
566
  }
350
567
  });
351
568
  }
569
+ getFieldUpdateGuards(db, model, args) {
570
+ var _a;
571
+ const allFieldGuards = [];
572
+ for (const [k, v] of Object.entries((_a = args.data) !== null && _a !== void 0 ? _a : args)) {
573
+ if (typeof v === 'undefined') {
574
+ continue;
575
+ }
576
+ const fieldGuard = this.getFieldUpdateAuthGuard(db, model, k);
577
+ if (this.isFalse(fieldGuard)) {
578
+ return { guard: allFieldGuards, rejectedByField: k };
579
+ }
580
+ allFieldGuards.push(fieldGuard);
581
+ }
582
+ return { guard: this.and(...allFieldGuards), rejectedByField: undefined };
583
+ }
584
+ /**
585
+ * Tries rejecting a request based on static "false" policy.
586
+ */
587
+ tryReject(db, model, operation) {
588
+ const guard = this.getAuthGuard(db, model, operation);
589
+ if (this.isFalse(guard)) {
590
+ throw this.deniedByPolicy(model, operation);
591
+ }
592
+ }
352
593
  /**
353
- * Process Prisma write actions.
594
+ * Checks if a model exists given a unique filter.
354
595
  */
355
- processWrite(model, action, args, writeAction) {
596
+ checkExistence(db, model, uniqueFilter, throwIfNotFound = false) {
356
597
  return __awaiter(this, void 0, void 0, function* () {
357
- // record model types for which new entities are created
358
- // so we can post-check if they satisfy 'create' policies
359
- const createdModels = new Set();
360
- // record model entities that are updated, together with their
361
- // values before update, so we can post-check if they satisfy
362
- // model => { ids, entity value }
363
- const updatedModels = new Map();
364
- function addUpdatedEntity(model, ids, entity) {
365
- let modelEntities = updatedModels.get(model);
366
- if (!modelEntities) {
367
- modelEntities = [];
368
- updatedModels.set(model, modelEntities);
369
- }
370
- modelEntities.push({ ids, value: entity });
371
- }
372
- const idFields = this.getIdFields(model);
373
- if (args.select) {
374
- // make sure id fields are selected, we need it to
375
- // read back the updated entity
376
- for (const idField of idFields) {
377
- if (!args.select[idField.name]) {
378
- args.select[idField.name] = true;
379
- }
380
- }
598
+ uniqueFilter = this.clone(uniqueFilter);
599
+ this.flattenGeneratedUniqueField(model, uniqueFilter);
600
+ if (this.shouldLogQuery) {
601
+ this.logger.info(`[policy] checking ${model} existence, \`findFirst\`:\n${(0, utils_1.formatObject)(uniqueFilter)}`);
381
602
  }
382
- // use a transaction to conduct write, so in case any create or nested create
383
- // fails access policies, we can roll back the entire operation
384
- const transactionId = (0, cuid2_1.createId)();
385
- // args processor for create
386
- const processCreate = (model, args) => __awaiter(this, void 0, void 0, function* () {
387
- const guard = yield this.getAuthGuard(model, 'create');
388
- const schema = yield this.getModelSchema(model);
389
- if (guard === false) {
390
- throw this.deniedByPolicy(model, 'create');
391
- }
392
- else if (guard !== true || schema) {
393
- // mark the create with a transaction tag so we can check them later
394
- args[constants_1.TRANSACTION_FIELD_NAME] = `${transactionId}:create`;
395
- createdModels.add(model);
396
- }
603
+ const existing = yield db[model].findFirst({
604
+ where: uniqueFilter,
605
+ select: this.makeIdSelection(model),
397
606
  });
398
- // build a reversed query for fetching entities affected by nested updates
399
- const buildReversedQuery = (context) => __awaiter(this, void 0, void 0, function* () {
400
- let result, currQuery;
401
- let currField;
402
- for (let i = context.nestingPath.length - 1; i >= 0; i--) {
403
- const { field, where, unique } = context.nestingPath[i];
404
- if (!result) {
405
- // first segment (bottom), just use its where clause
406
- result = currQuery = Object.assign({}, where);
407
- currField = field;
408
- }
409
- else {
410
- if (!currField) {
411
- throw this.unknownError(`missing field in nested path`);
412
- }
413
- if (!currField.backLink) {
414
- throw this.unknownError(`field ${currField.type}.${currField.name} doesn't have a backLink`);
415
- }
416
- currQuery[currField.backLink] = Object.assign({}, where);
417
- currQuery = currQuery[currField.backLink];
418
- currField = field;
419
- }
420
- if (unique) {
421
- // hit a unique filter, no need to traverse further up
422
- break;
423
- }
424
- }
425
- return result;
426
- });
427
- // args processor for update/upsert
428
- const processUpdate = (model, where, context) => __awaiter(this, void 0, void 0, function* () {
429
- const preGuard = yield this.getAuthGuard(model, 'update');
430
- if (preGuard === false) {
431
- throw this.deniedByPolicy(model, 'update');
432
- }
433
- else if (preGuard !== true) {
434
- if (this.isToOneRelation(context.field)) {
435
- // To-one relation field is complicated because there's no way to
436
- // filter it during update (args doesn't carry a 'where' clause).
437
- //
438
- // We need to recursively walk up its hierarcy in the query args
439
- // to construct a reversed query to identify the nested entity
440
- // under update, and then check if it satisfies policy.
441
- //
442
- // E.g.:
443
- // A - B - C
444
- //
445
- // update A with:
446
- // {
447
- // where: { id: 'aId' },
448
- // data: {
449
- // b: {
450
- // c: { value: 1 }
451
- // }
452
- // }
453
- // }
454
- //
455
- // To check if the update to 'c' field is permitted, we
456
- // reverse the query stack into a filter for C model, like:
457
- // {
458
- // where: {
459
- // b: { a: { id: 'aId' } }
460
- // }
461
- // }
462
- // , and with this we can filter out the C entity that's going
463
- // to be nestedly updated, and check if it's allowed.
464
- //
465
- // The same logic applies to nested delete.
466
- const subQuery = yield buildReversedQuery(context);
467
- yield this.checkPolicyForFilter(model, subQuery, 'update', this.db);
468
- }
469
- else {
470
- if (!where) {
471
- throw this.unknownError(`Missing 'where' parameter`);
472
- }
473
- yield this.checkPolicyForFilter(model, where, 'update', this.db);
474
- }
475
- }
476
- yield preparePostUpdateCheck(model, context);
477
- });
478
- // args processor for updateMany
479
- const processUpdateMany = (model, args, context) => __awaiter(this, void 0, void 0, function* () {
480
- const guard = yield this.getAuthGuard(model, 'update');
481
- if (guard === false) {
482
- throw this.deniedByPolicy(model, 'update');
483
- }
484
- else if (guard !== true) {
485
- // inject policy filter
486
- yield this.injectAuthGuard(args, model, 'update');
487
- }
488
- yield preparePostUpdateCheck(model, context);
489
- });
490
- // for models with post-update rules, we need to read and store
491
- // entity values before the update for post-update check
492
- const preparePostUpdateCheck = (model, context) => __awaiter(this, void 0, void 0, function* () {
493
- const postGuard = yield this.getAuthGuard(model, 'postUpdate');
494
- const schema = yield this.getModelSchema(model);
495
- // post-update check is needed if there's post-update rule or validation schema
496
- if (postGuard !== true || schema) {
497
- // fetch preValue selection (analyzed from the post-update rules)
498
- const preValueSelect = yield this.getPreValueSelect(model);
499
- const filter = yield buildReversedQuery(context);
500
- // query args will be used with findMany, so we need to
501
- // translate unique constraint filters into a flat filter
502
- // e.g.: { a_b: { a: '1', b: '1' } } => { a: '1', b: '1' }
503
- yield this.flattenGeneratedUniqueField(model, filter);
504
- const idFields = this.getIdFields(model);
505
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
506
- const select = Object.assign({}, preValueSelect);
507
- for (const idField of idFields) {
508
- select[idField.name] = true;
509
- }
510
- const query = { where: filter, select };
511
- if (this.shouldLogQuery) {
512
- this.logger.info(`[withPolicy] \`findMany\` for fetching pre-update entities:\n${(0, utils_1.formatObject)(args)}`);
513
- }
514
- const entities = yield this.db[model].findMany(query);
515
- entities.forEach((entity) => {
516
- addUpdatedEntity(model, this.getEntityIds(model, entity), entity);
517
- });
518
- }
519
- });
520
- // args processor for delete
521
- const processDelete = (model, args, context) => __awaiter(this, void 0, void 0, function* () {
522
- const guard = yield this.getAuthGuard(model, 'delete');
523
- if (guard === false) {
524
- throw this.deniedByPolicy(model, 'delete');
525
- }
526
- else if (guard !== true) {
527
- if (this.isToOneRelation(context.field)) {
528
- // see comments in processUpdate
529
- const subQuery = yield buildReversedQuery(context);
530
- yield this.checkPolicyForFilter(model, subQuery, 'delete', this.db);
531
- }
532
- else {
533
- yield this.checkPolicyForFilter(model, args, 'delete', this.db);
534
- }
535
- }
536
- });
537
- // process relation updates: connect, connectOrCreate, and disconnect
538
- const processRelationUpdate = (model, args, context) => __awaiter(this, void 0, void 0, function* () {
539
- var _a;
540
- if ((_a = context.field) === null || _a === void 0 ? void 0 : _a.backLink) {
541
- // fetch the backlink field of the model being connected
542
- const backLinkField = (0, model_meta_1.resolveField)(this.modelMeta, model, context.field.backLink);
543
- if (backLinkField.isRelationOwner) {
544
- // the target side of relation owns the relation,
545
- // mark it as updated
546
- yield processUpdate(model, args, context);
607
+ if (!existing && throwIfNotFound) {
608
+ throw this.notFound(model);
609
+ }
610
+ return existing;
611
+ });
612
+ }
613
+ /**
614
+ * Returns an entity given a unique filter with read policy checked. Reject if not readable.
615
+ */
616
+ readBack(db, model, operation, selectInclude, uniqueFilter) {
617
+ return __awaiter(this, void 0, void 0, function* () {
618
+ uniqueFilter = this.clone(uniqueFilter);
619
+ this.flattenGeneratedUniqueField(model, uniqueFilter);
620
+ const readArgs = { select: selectInclude.select, include: selectInclude.include, where: uniqueFilter };
621
+ const error = this.deniedByPolicy(model, operation, 'result is not allowed to be read back', constants_1.CrudFailureReason.RESULT_NOT_READABLE);
622
+ const injectResult = yield this.injectForRead(db, model, readArgs);
623
+ if (!injectResult) {
624
+ return { error, result: undefined };
625
+ }
626
+ // inject select needed for field-level read checks
627
+ this.injectReadCheckSelect(model, readArgs);
628
+ if (this.shouldLogQuery) {
629
+ this.logger.info(`[policy] checking read-back, \`findFirst\` ${model}:\n${(0, utils_1.formatObject)(readArgs)}`);
630
+ }
631
+ const result = yield db[model].findFirst(readArgs);
632
+ if (!result) {
633
+ return { error, result: undefined };
634
+ }
635
+ this.postProcessForRead(result, model, selectInclude);
636
+ return { result, error: undefined };
637
+ });
638
+ }
639
+ /**
640
+ * Injects field selection needed for checking field-level read policy into query args.
641
+ * @returns
642
+ */
643
+ injectReadCheckSelect(model, args) {
644
+ if (!this.hasFieldLevelPolicy(model)) {
645
+ return;
646
+ }
647
+ const readFieldSelect = this.getReadFieldSelect(model);
648
+ if (!readFieldSelect) {
649
+ return;
650
+ }
651
+ this.doInjectReadCheckSelect(model, args, { select: readFieldSelect });
652
+ }
653
+ doInjectReadCheckSelect(model, args, input) {
654
+ if (!(input === null || input === void 0 ? void 0 : input.select)) {
655
+ return;
656
+ }
657
+ let target; // injection target
658
+ let isInclude = false; // if the target is include or select
659
+ if (args.select) {
660
+ target = args.select;
661
+ isInclude = false;
662
+ }
663
+ else if (args.include) {
664
+ target = args.include;
665
+ isInclude = true;
666
+ }
667
+ else {
668
+ target = args.select = this.makeAllScalarFieldSelect(model);
669
+ isInclude = false;
670
+ }
671
+ if (!isInclude) {
672
+ // merge selects
673
+ for (const [k, v] of Object.entries(input.select)) {
674
+ if (v === true) {
675
+ if (!target[k]) {
676
+ target[k] = true;
547
677
  }
548
678
  }
549
- });
550
- // use a visitor to process args before conducting the write action
551
- const visitor = new nested_write_vistor_1.NestedWriteVisitor(this.modelMeta, {
552
- create: (model, args) => __awaiter(this, void 0, void 0, function* () {
553
- yield processCreate(model, args);
554
- }),
555
- connectOrCreate: (model, args, context) => __awaiter(this, void 0, void 0, function* () {
556
- if (args.create) {
557
- yield processCreate(model, args.create);
558
- }
559
- if (args.where) {
560
- yield processRelationUpdate(model, args.where, context);
561
- }
562
- }),
563
- connect: (model, args, context) => __awaiter(this, void 0, void 0, function* () {
564
- yield processRelationUpdate(model, args, context);
565
- }),
566
- disconnect: (model, args, context) => __awaiter(this, void 0, void 0, function* () {
567
- yield processRelationUpdate(model, args, context);
568
- }),
569
- update: (model, args, context) => __awaiter(this, void 0, void 0, function* () {
570
- yield processUpdate(model, args.where, context);
571
- }),
572
- updateMany: (model, args, context) => __awaiter(this, void 0, void 0, function* () {
573
- yield processUpdateMany(model, args, context);
574
- }),
575
- upsert: (model, args, context) => __awaiter(this, void 0, void 0, function* () {
576
- if (args.create) {
577
- yield processCreate(model, args.create);
578
- }
579
- if (args.update) {
580
- yield processUpdate(model, args.where, context);
581
- }
582
- }),
583
- delete: (model, args, context) => __awaiter(this, void 0, void 0, function* () {
584
- yield processDelete(model, args, context);
585
- }),
586
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
587
- deleteMany: (model, args, _context) => __awaiter(this, void 0, void 0, function* () {
588
- const guard = yield this.getAuthGuard(model, 'delete');
589
- if (guard === false) {
590
- throw this.deniedByPolicy(model, 'delete');
591
- }
592
- else if (guard !== true) {
593
- if (args.where) {
594
- args.where = this.and(args.where, guard);
595
- }
596
- else {
597
- const copy = (0, deepcopy_1.default)(args);
598
- for (const key of Object.keys(args)) {
599
- delete args[key];
600
- }
601
- const combined = this.and(copy, guard);
602
- Object.assign(args, combined);
603
- }
604
- }
605
- }),
606
- });
607
- yield visitor.visit(model, action, args);
608
- if (createdModels.size === 0 && updatedModels.size === 0) {
609
- // no post-check needed, we can proceed with the write without transaction
610
- return yield writeAction(this.db[model], args);
611
679
  }
612
- else {
613
- return yield this.transaction(this.db, (tx) => __awaiter(this, void 0, void 0, function* () {
614
- // proceed with the update (with args processed)
615
- const result = yield writeAction(tx[model], args);
616
- if (createdModels.size > 0) {
617
- // do post-check on created entities
618
- yield Promise.all([...createdModels].map((model) => this.checkPolicyForFilter(model, { [constants_1.TRANSACTION_FIELD_NAME]: `${transactionId}:create` }, 'create', tx)));
680
+ }
681
+ // recurse into nested selects (relation fields)
682
+ for (const [k, v] of Object.entries(input.select)) {
683
+ if (typeof v === 'object' && (v === null || v === void 0 ? void 0 : v.select)) {
684
+ const field = (0, model_meta_1.resolveField)(this.modelMeta, model, k);
685
+ if (field === null || field === void 0 ? void 0 : field.isDataModel) {
686
+ // recurse into relation
687
+ if (isInclude && target[k] === true) {
688
+ // select all fields for the relation
689
+ target[k] = { select: this.makeAllScalarFieldSelect(field.type) };
619
690
  }
620
- if (updatedModels.size > 0) {
621
- // do post-check on updated entities
622
- yield Promise.all([...updatedModels.entries()]
623
- .map(([model, modelEntities]) => modelEntities.map(({ ids, value: preValue }) => __awaiter(this, void 0, void 0, function* () { return this.checkPostUpdate(model, ids, tx, preValue); })))
624
- .flat());
691
+ else if (!target[k]) {
692
+ // ensure an empty select clause
693
+ target[k] = { select: {} };
625
694
  }
626
- return result;
627
- }));
695
+ // recurse
696
+ this.doInjectReadCheckSelect(field.type, target[k], v);
697
+ }
628
698
  }
629
- });
630
- }
631
- transaction(db, action) {
632
- if (db.__zenstack_tx) {
633
- // already in transaction, don't nest
634
- return action(db);
635
699
  }
636
- else {
637
- return db.$transaction((tx) => action(tx));
700
+ }
701
+ makeAllScalarFieldSelect(model) {
702
+ const fields = this.modelMeta.fields[(0, lower_case_first_1.lowerCaseFirst)(model)];
703
+ const result = {};
704
+ if (fields) {
705
+ Object.entries(fields).forEach(([k, v]) => {
706
+ if (!v.isDataModel) {
707
+ result[k] = true;
708
+ }
709
+ });
638
710
  }
711
+ return result;
639
712
  }
713
+ //#endregion
714
+ //#region Errors
640
715
  deniedByPolicy(model, operation, extra, reason) {
641
- return (0, utils_1.prismaClientKnownRequestError)(this.db, `denied by policy: ${model} entities failed '${operation}' check${extra ? ', ' + extra : ''}`, { clientVersion: (0, version_1.getVersion)(), code: 'P2004', meta: { reason } });
716
+ return (0, utils_1.prismaClientKnownRequestError)(this.db, `denied by policy: ${model} entities failed '${operation}' check${extra ? ', ' + extra : ''}`, { clientVersion: (0, version_1.getVersion)(), code: constants_1.PrismaErrorCode.CONSTRAINED_FAILED, meta: { reason } });
642
717
  }
643
718
  notFound(model) {
644
719
  return (0, utils_1.prismaClientKnownRequestError)(this.db, `entity not found for model ${model}`, {
@@ -646,122 +721,151 @@ class PolicyUtil {
646
721
  code: 'P2025',
647
722
  });
648
723
  }
724
+ validationError(message) {
725
+ return (0, utils_1.prismaClientValidationError)(this.db, message, {
726
+ clientVersion: (0, version_1.getVersion)(),
727
+ });
728
+ }
649
729
  unknownError(message) {
650
730
  return (0, utils_1.prismaClientUnknownRequestError)(this.db, message, {
651
731
  clientVersion: (0, version_1.getVersion)(),
652
732
  });
653
733
  }
734
+ //#endregion
735
+ //#region Misc
654
736
  /**
655
- * Given a filter, check if applying access policy filtering will result
656
- * in data being trimmed, and if so, throw an error.
737
+ * Gets field selection for fetching pre-update entity values for the given model.
657
738
  */
658
- checkPolicyForFilter(model, filter, operation, db) {
659
- return __awaiter(this, void 0, void 0, function* () {
660
- const guard = yield this.getAuthGuard(model, operation);
661
- const schema = (operation === 'create' || operation === 'update') && (yield this.getModelSchema(model));
662
- if (guard === true && !schema) {
663
- // unconditionally allowed
664
- return;
665
- }
666
- // if (this.logger.enabled('info')) {
667
- // this.logger.info(`Checking policy for ${model}#${JSON.stringify(filter)} for ${operation}`);
668
- // }
669
- const queryFilter = (0, deepcopy_1.default)(filter);
670
- // query args will be used with findMany, so we need to
671
- // translate unique constraint filters into a flat filter
672
- // e.g.: { a_b: { a: '1', b: '1' } } => { a: '1', b: '1' }
673
- yield this.flattenGeneratedUniqueField(model, queryFilter);
674
- const countArgs = { where: queryFilter };
675
- // if (this.shouldLogQuery) {
676
- // this.logger.info(
677
- // `[withPolicy] \`count\` for policy check without guard:\n${formatObject(countArgs)}`
678
- // );
679
- // }
680
- const count = (yield db[model].count(countArgs));
681
- if (count === 0) {
682
- // there's nothing to filter out
739
+ getPreValueSelect(model) {
740
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
741
+ if (!guard) {
742
+ throw this.unknownError(`unable to load policy guard for ${model}`);
743
+ }
744
+ return guard[constants_1.PRE_UPDATE_VALUE_SELECTOR];
745
+ }
746
+ getReadFieldSelect(model) {
747
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
748
+ if (!guard) {
749
+ throw this.unknownError(`unable to load policy guard for ${model}`);
750
+ }
751
+ return guard[constants_1.FIELD_LEVEL_READ_CHECKER_SELECTOR];
752
+ }
753
+ checkReadField(model, field, entity) {
754
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
755
+ if (!guard) {
756
+ throw this.unknownError(`unable to load policy guard for ${model}`);
757
+ }
758
+ const func = guard[`${constants_1.FIELD_LEVEL_READ_CHECKER_PREFIX}${field}`];
759
+ if (!func) {
760
+ return true;
761
+ }
762
+ else {
763
+ return func(entity, { user: this.user });
764
+ }
765
+ }
766
+ hasFieldValidation(model) {
767
+ var _a, _b;
768
+ return ((_b = (_a = this.policy.validation) === null || _a === void 0 ? void 0 : _a[(0, lower_case_first_1.lowerCaseFirst)(model)]) === null || _b === void 0 ? void 0 : _b.hasValidation) === true;
769
+ }
770
+ hasFieldLevelPolicy(model) {
771
+ const guard = this.policy.guard[(0, lower_case_first_1.lowerCaseFirst)(model)];
772
+ if (!guard) {
773
+ throw this.unknownError(`unable to load policy guard for ${model}`);
774
+ }
775
+ return !!guard[constants_1.HAS_FIELD_LEVEL_POLICY_FLAG];
776
+ }
777
+ /**
778
+ * Gets Zod schema for the given model and access kind.
779
+ *
780
+ * @param kind If undefined, returns the full schema.
781
+ */
782
+ getZodSchema(model, kind = undefined) {
783
+ var _a, _b;
784
+ if (!this.hasFieldValidation(model)) {
785
+ return undefined;
786
+ }
787
+ const schemaKey = `${(0, upper_case_first_1.upperCaseFirst)(model)}${kind ? (0, upper_case_first_1.upperCaseFirst)(kind) : ''}Schema`;
788
+ return (_b = (_a = this.zodSchemas) === null || _a === void 0 ? void 0 : _a.models) === null || _b === void 0 ? void 0 : _b[schemaKey];
789
+ }
790
+ /**
791
+ * Post processing checks and clean-up for read model entities.
792
+ */
793
+ postProcessForRead(data, model, queryArgs) {
794
+ // preserve the original data as it may be needed for checking field-level readability,
795
+ // while the "data" will be manipulated during traversal (deleting unreadable fields)
796
+ const origData = this.clone(data);
797
+ this.doPostProcessForRead(data, model, origData, queryArgs, this.hasFieldLevelPolicy(model));
798
+ }
799
+ doPostProcessForRead(data, model, fullData, queryArgs, hasFieldLevelPolicy, path = '') {
800
+ var _a, _b;
801
+ if (data === null || data === undefined) {
802
+ return;
803
+ }
804
+ for (const [entityData, entityFullData] of (0, utils_1.zip)(data, fullData)) {
805
+ if (typeof entityData !== 'object' || !entityData) {
683
806
  return;
684
807
  }
685
- if (guard === false) {
686
- // unconditionally denied
687
- throw this.deniedByPolicy(model, operation, `${count} ${(0, pluralize_1.default)('entity', count)} failed policy check`);
808
+ // strip auxiliary fields
809
+ for (const auxField of constants_1.AUXILIARY_FIELDS) {
810
+ if (auxField in entityData) {
811
+ delete entityData[auxField];
812
+ }
688
813
  }
689
- // build a query condition with policy injected
690
- const guardedQuery = { where: this.and(queryFilter, guard) };
691
- if (schema) {
692
- // we've got schemas, so have to fetch entities and validate them
693
- // if (this.shouldLogQuery) {
694
- // this.logger.info(
695
- // `[withPolicy] \`findMany\` for policy check with guard:\n${formatObject(countArgs)}`
696
- // );
697
- // }
698
- const entities = yield db[model].findMany(guardedQuery);
699
- if (entities.length < count) {
700
- if (this.logger.enabled('info')) {
701
- this.logger.info(`entity ${model} failed policy check for operation ${operation}`);
702
- }
703
- throw this.deniedByPolicy(model, operation, `${count - entities.length} ${(0, pluralize_1.default)('entity', count - entities.length)} failed policy check`);
814
+ for (const [field, fieldData] of Object.entries(entityData)) {
815
+ if (fieldData === undefined) {
816
+ continue;
704
817
  }
705
- // TODO: push down schema check to the database
706
- const schemaCheckErrors = entities.map((entity) => schema.safeParse(entity)).filter((r) => !r.success);
707
- if (schemaCheckErrors.length > 0) {
708
- const error = schemaCheckErrors.map((r) => !r.success && (0, zod_validation_error_1.fromZodError)(r.error).message).join(', ');
709
- if (this.logger.enabled('info')) {
710
- this.logger.info(`entity ${model} failed schema check for operation ${operation}: ${error}`);
711
- }
712
- throw this.deniedByPolicy(model, operation, `entities failed schema check: [${error}]`);
818
+ const fieldInfo = (0, model_meta_1.resolveField)(this.modelMeta, model, field);
819
+ if (!fieldInfo) {
820
+ // could be _count, etc.
821
+ continue;
713
822
  }
714
- }
715
- else {
716
- // count entities with policy injected and see if any of them are filtered out
717
- // if (this.shouldLogQuery) {
718
- // this.logger.info(
719
- // `[withPolicy] \`count\` for policy check with guard:\n${formatObject(guardedQuery)}`
720
- // );
721
- // }
722
- const guardedCount = (yield db[model].count(guardedQuery));
723
- if (guardedCount < count) {
724
- if (this.logger.enabled('info')) {
725
- this.logger.info(`entity ${model} failed policy check for operation ${operation}`);
823
+ if (hasFieldLevelPolicy) {
824
+ // 1. remove fields selected for checking field-level policies but not selected by the original query args
825
+ // 2. evaluate field-level policies and remove fields that are not readable
826
+ if (!fieldInfo.isDataModel) {
827
+ // scalar field, delete unselected ones
828
+ const select = queryArgs === null || queryArgs === void 0 ? void 0 : queryArgs.select;
829
+ if (select && typeof select === 'object' && select[field] !== true) {
830
+ // there's a select clause but this field is not included
831
+ delete entityData[field];
832
+ continue;
833
+ }
726
834
  }
727
- throw this.deniedByPolicy(model, operation, `${count - guardedCount} ${(0, pluralize_1.default)('entity', count - guardedCount)} failed policy check`);
728
- }
729
- }
730
- });
731
- }
732
- checkPostUpdate(model, ids, db, preValue) {
733
- return __awaiter(this, void 0, void 0, function* () {
734
- // if (this.logger.enabled('info')) {
735
- // this.logger.info(`Checking post-update policy for ${model}#${ids}, preValue: ${formatObject(preValue)}`);
736
- // }
737
- const guard = yield this.getAuthGuard(model, 'postUpdate', preValue);
738
- // build a query condition with policy injected
739
- const guardedQuery = { where: this.and(ids, guard) };
740
- // query with policy injected
741
- const entity = yield db[model].findFirst(guardedQuery);
742
- // see if we get fewer items with policy, if so, reject with an throw
743
- if (!entity) {
744
- if (this.logger.enabled('info')) {
745
- this.logger.info(`entity ${model} failed policy check for operation postUpdate`);
746
- }
747
- throw this.deniedByPolicy(model, 'postUpdate');
748
- }
749
- // TODO: push down schema check to the database
750
- const schema = yield this.getModelSchema(model);
751
- if (schema) {
752
- const schemaCheckResult = schema.safeParse(entity);
753
- if (!schemaCheckResult.success) {
754
- const error = (0, zod_validation_error_1.fromZodError)(schemaCheckResult.error).message;
755
- if (this.logger.enabled('info')) {
756
- this.logger.info(`entity ${model} failed schema check for operation postUpdate: ${error}`);
835
+ else {
836
+ // relation field, delete if not selected or included
837
+ const include = queryArgs === null || queryArgs === void 0 ? void 0 : queryArgs.include;
838
+ const select = queryArgs === null || queryArgs === void 0 ? void 0 : queryArgs.select;
839
+ if (!(include === null || include === void 0 ? void 0 : include[field]) && !(select === null || select === void 0 ? void 0 : select[field])) {
840
+ // relation field not included or selected
841
+ delete entityData[field];
842
+ continue;
843
+ }
844
+ }
845
+ // delete unreadable fields
846
+ if (!this.checkReadField(model, field, entityFullData)) {
847
+ if (this.shouldLogQuery) {
848
+ this.logger.info(`[policy] dropping unreadable field ${path ? path + '.' : ''}${field}`);
849
+ }
850
+ delete entityData[field];
851
+ continue;
757
852
  }
758
- throw this.deniedByPolicy(model, 'postUpdate', `entity failed schema check: ${error}`);
853
+ }
854
+ if (fieldInfo.isDataModel) {
855
+ // recurse into nested fields
856
+ const nextArgs = (_b = ((_a = queryArgs === null || queryArgs === void 0 ? void 0 : queryArgs.select) !== null && _a !== void 0 ? _a : queryArgs === null || queryArgs === void 0 ? void 0 : queryArgs.include)) === null || _b === void 0 ? void 0 : _b[field];
857
+ this.doPostProcessForRead(fieldData, fieldInfo.type, entityFullData[field], nextArgs, hasFieldLevelPolicy, path ? path + '.' + field : field);
759
858
  }
760
859
  }
761
- });
860
+ }
762
861
  }
763
- isToOneRelation(field) {
764
- return !!field && field.isDataModel && !field.isArray;
862
+ /**
863
+ * Gets information for a specific model field.
864
+ */
865
+ getModelField(model, field) {
866
+ var _a;
867
+ model = (0, lower_case_first_1.lowerCaseFirst)(model);
868
+ return (_a = this.modelMeta.fields[model]) === null || _a === void 0 ? void 0 : _a[field];
765
869
  }
766
870
  /**
767
871
  * Clones an object and makes sure it's not empty.
@@ -770,13 +874,13 @@ class PolicyUtil {
770
874
  return value ? (0, deepcopy_1.default)(value) : {};
771
875
  }
772
876
  /**
773
- * Gets "id" field for a given model.
877
+ * Gets "id" fields for a given model.
774
878
  */
775
879
  getIdFields(model) {
776
880
  return (0, utils_1.getIdFields)(this.modelMeta, model, true);
777
881
  }
778
882
  /**
779
- * Gets id field value from an entity.
883
+ * Gets id field values from an entity.
780
884
  */
781
885
  getEntityIds(model, entityData) {
782
886
  const idFields = this.getIdFields(model);
@@ -786,8 +890,37 @@ class PolicyUtil {
786
890
  }
787
891
  return result;
788
892
  }
789
- get shouldLogQuery() {
790
- return this.logPrismaQuery && this.logger.enabled('info');
893
+ /**
894
+ * Creates a selection object for id fields for the given model.
895
+ */
896
+ makeIdSelection(model) {
897
+ const idFields = this.getIdFields(model);
898
+ return Object.assign({}, ...idFields.map((f) => ({ [f.name]: true })));
899
+ }
900
+ mergeWhereClause(where, extra) {
901
+ var _a;
902
+ if (!where) {
903
+ throw new Error('invalid where clause');
904
+ }
905
+ extra = this.reduce(extra);
906
+ if (this.isTrue(extra)) {
907
+ return;
908
+ }
909
+ // instead of simply wrapping with AND, we preserve the structure
910
+ // of the original where clause and merge `extra` into it so that
911
+ // unique query can continue working
912
+ if (where.AND) {
913
+ // merge into existing AND clause
914
+ const conditions = Array.isArray(where.AND) ? [...where.AND] : [where.AND];
915
+ conditions.push(extra);
916
+ const combined = this.and(...conditions);
917
+ // make sure the merging always goes under AND
918
+ where.AND = (_a = combined.AND) !== null && _a !== void 0 ? _a : combined;
919
+ }
920
+ else {
921
+ // insert an AND clause
922
+ where.AND = [extra];
923
+ }
791
924
  }
792
925
  }
793
926
  exports.PolicyUtil = PolicyUtil;