Skip to content

Commit dcfa6c3

Browse files
authored
fix(policy): validator fixes, more tests migrated (#258)
1 parent e7ba1d6 commit dcfa6c3

File tree

16 files changed

+1425
-35
lines changed

16 files changed

+1425
-35
lines changed

packages/runtime/src/client/crud/operations/base.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,8 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
279279

280280
if (!ownedByModel) {
281281
// assign fks from parent
282-
const parentFkFields = this.buildFkAssignments(
282+
const parentFkFields = await this.buildFkAssignments(
283+
kysely,
283284
fromRelation.model,
284285
fromRelation.field,
285286
fromRelation.ids,
@@ -433,7 +434,12 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
433434
return { baseEntity, remainingFields };
434435
}
435436

436-
private buildFkAssignments(model: string, relationField: string, entity: any) {
437+
private async buildFkAssignments(
438+
kysely: ToKysely<Schema>,
439+
model: GetModels<Schema>,
440+
relationField: string,
441+
entity: any,
442+
) {
437443
const parentFkFields: any = {};
438444

439445
invariant(relationField, 'parentField must be defined if parentModel is defined');
@@ -443,7 +449,18 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
443449

444450
for (const pair of keyPairs) {
445451
if (!(pair.pk in entity)) {
446-
throw new QueryError(`Field "${pair.pk}" not found in parent created data`);
452+
// the relation may be using a non-id field as fk, so we read in-place
453+
// to fetch that field
454+
const extraRead = await this.readUnique(kysely, model, {
455+
where: entity,
456+
select: { [pair.pk]: true },
457+
} as any);
458+
if (!extraRead) {
459+
throw new QueryError(`Field "${pair.pk}" not found in parent created data`);
460+
} else {
461+
// update the parent entity
462+
Object.assign(entity, extraRead);
463+
}
447464
}
448465
Object.assign(parentFkFields, {
449466
[pair.fk]: (entity as any)[pair.pk],
@@ -1411,7 +1428,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
14111428
...(enumerate(value) as { where: any; data: any }[]).map((item) => {
14121429
let where;
14131430
let data;
1414-
if ('where' in item) {
1431+
if ('data' in item && typeof item.data === 'object') {
14151432
where = item.where;
14161433
data = item.data;
14171434
} else {

packages/runtime/src/client/crud/operations/create.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { match } from 'ts-pattern';
2-
import { RejectedByPolicyError } from '../../../plugins/policy/errors';
2+
import { RejectedByPolicyError, RejectedByPolicyReason } from '../../../plugins/policy/errors';
33
import type { GetModels, SchemaDef } from '../../../schema';
44
import type { CreateArgs, CreateManyAndReturnArgs, CreateManyArgs, WhereInput } from '../../crud-types';
55
import { getIdValues } from '../../query-utils';
@@ -40,7 +40,11 @@ export class CreateOperationHandler<Schema extends SchemaDef> extends BaseOperat
4040
});
4141

4242
if (!result && this.hasPolicyEnabled) {
43-
throw new RejectedByPolicyError(this.model, `result is not allowed to be read back`);
43+
throw new RejectedByPolicyError(
44+
this.model,
45+
RejectedByPolicyReason.CANNOT_READ_BACK,
46+
`result is not allowed to be read back`,
47+
);
4448
}
4549

4650
return result;

packages/runtime/src/client/crud/operations/delete.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SchemaDef } from '../../../schema';
33
import type { DeleteArgs, DeleteManyArgs } from '../../crud-types';
44
import { NotFoundError } from '../../errors';
55
import { BaseOperationHandler } from './base';
6+
import { RejectedByPolicyError, RejectedByPolicyReason } from '../../../plugins/policy';
67

78
export class DeleteOperationHandler<Schema extends SchemaDef> extends BaseOperationHandler<Schema> {
89
async handle(operation: 'delete' | 'deleteMany', args: unknown | undefined) {
@@ -24,9 +25,6 @@ export class DeleteOperationHandler<Schema extends SchemaDef> extends BaseOperat
2425
omit: args.omit,
2526
where: args.where,
2627
});
27-
if (!existing) {
28-
throw new NotFoundError(this.model);
29-
}
3028

3129
// TODO: avoid using transaction for simple delete
3230
await this.safeTransaction(async (tx) => {
@@ -36,6 +34,14 @@ export class DeleteOperationHandler<Schema extends SchemaDef> extends BaseOperat
3634
}
3735
});
3836

37+
if (!existing && this.hasPolicyEnabled) {
38+
throw new RejectedByPolicyError(
39+
this.model,
40+
RejectedByPolicyReason.CANNOT_READ_BACK,
41+
'result is not allowed to be read back',
42+
);
43+
}
44+
3945
return existing;
4046
}
4147

packages/runtime/src/client/crud/operations/update.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { match } from 'ts-pattern';
2-
import { RejectedByPolicyError } from '../../../plugins/policy/errors';
2+
import { RejectedByPolicyError, RejectedByPolicyReason } from '../../../plugins/policy/errors';
33
import type { GetModels, SchemaDef } from '../../../schema';
44
import type { UpdateArgs, UpdateManyAndReturnArgs, UpdateManyArgs, UpsertArgs, WhereInput } from '../../crud-types';
55
import { getIdValues } from '../../query-utils';
@@ -48,7 +48,11 @@ export class UpdateOperationHandler<Schema extends SchemaDef> extends BaseOperat
4848
// update succeeded but result cannot be read back
4949
if (this.hasPolicyEnabled) {
5050
// if access policy is enabled, we assume it's due to read violation (not guaranteed though)
51-
throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back');
51+
throw new RejectedByPolicyError(
52+
this.model,
53+
RejectedByPolicyReason.CANNOT_READ_BACK,
54+
'result is not allowed to be read back',
55+
);
5256
} else {
5357
// this can happen if the entity is cascade deleted during the update, return null to
5458
// be consistent with Prisma even though it doesn't comply with the method signature
@@ -71,16 +75,29 @@ export class UpdateOperationHandler<Schema extends SchemaDef> extends BaseOperat
7175
return [];
7276
}
7377

74-
return this.safeTransaction(async (tx) => {
78+
const { readBackResult, updateResult } = await this.safeTransaction(async (tx) => {
7579
const updateResult = await this.updateMany(tx, this.model, args.where, args.data, args.limit, true);
76-
return this.read(tx, this.model, {
80+
const readBackResult = await this.read(tx, this.model, {
7781
select: args.select,
7882
omit: args.omit,
7983
where: {
8084
OR: updateResult.map((item) => getIdValues(this.schema, this.model, item) as any),
8185
} as any, // TODO: fix type
8286
});
87+
88+
return { readBackResult, updateResult };
8389
});
90+
91+
if (readBackResult.length < updateResult.length && this.hasPolicyEnabled) {
92+
// some of the updated entities cannot be read back
93+
throw new RejectedByPolicyError(
94+
this.model,
95+
RejectedByPolicyReason.CANNOT_READ_BACK,
96+
'result is not allowed to be read back',
97+
);
98+
}
99+
100+
return readBackResult;
84101
}
85102

86103
private async runUpsert(args: UpsertArgs<Schema, GetModels<Schema>>) {
@@ -113,7 +130,11 @@ export class UpdateOperationHandler<Schema extends SchemaDef> extends BaseOperat
113130
});
114131

115132
if (!result && this.hasPolicyEnabled) {
116-
throw new RejectedByPolicyError(this.model, 'result is not allowed to be read back');
133+
throw new RejectedByPolicyError(
134+
this.model,
135+
RejectedByPolicyReason.CANNOT_READ_BACK,
136+
'result is not allowed to be read back',
137+
);
117138
}
118139

119140
return result;

packages/runtime/src/client/crud/validator.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { invariant } from '@zenstackhq/common-helpers';
22
import Decimal from 'decimal.js';
33
import stableStringify from 'json-stable-stringify';
44
import { match, P } from 'ts-pattern';
5-
import { z, ZodType } from 'zod';
5+
import { z, ZodSchema, ZodType } from 'zod';
66
import {
77
type BuiltinType,
88
type EnumDef,
@@ -764,13 +764,15 @@ export class InputValidator<Schema extends SchemaDef> {
764764

765765
private makeCreateSchema(model: string) {
766766
const dataSchema = this.makeCreateDataSchema(model, false);
767-
const schema = z.strictObject({
767+
let schema: ZodSchema = z.strictObject({
768768
data: dataSchema,
769769
select: this.makeSelectSchema(model).optional(),
770770
include: this.makeIncludeSchema(model).optional(),
771771
omit: this.makeOmitSchema(model).optional(),
772772
});
773-
return this.refineForSelectIncludeMutuallyExclusive(schema);
773+
schema = this.refineForSelectIncludeMutuallyExclusive(schema);
774+
schema = this.refineForSelectOmitMutuallyExclusive(schema);
775+
return schema;
774776
}
775777

776778
private makeCreateManySchema(model: string) {
@@ -934,15 +936,15 @@ export class InputValidator<Schema extends SchemaDef> {
934936
fields['update'] = array
935937
? this.orArray(
936938
z.strictObject({
937-
where: this.makeWhereSchema(fieldType, true),
939+
where: this.makeWhereSchema(fieldType, true).optional(),
938940
data: this.makeUpdateDataSchema(fieldType, withoutFields),
939941
}),
940942
true,
941943
).optional()
942944
: z
943945
.union([
944946
z.strictObject({
945-
where: this.makeWhereSchema(fieldType, true),
947+
where: this.makeWhereSchema(fieldType, true).optional(),
946948
data: this.makeUpdateDataSchema(fieldType, withoutFields),
947949
}),
948950
this.makeUpdateDataSchema(fieldType, withoutFields),
@@ -1026,14 +1028,16 @@ export class InputValidator<Schema extends SchemaDef> {
10261028
// #region Update
10271029

10281030
private makeUpdateSchema(model: string) {
1029-
const schema = z.strictObject({
1031+
let schema: ZodSchema = z.strictObject({
10301032
where: this.makeWhereSchema(model, true),
10311033
data: this.makeUpdateDataSchema(model),
10321034
select: this.makeSelectSchema(model).optional(),
10331035
include: this.makeIncludeSchema(model).optional(),
10341036
omit: this.makeOmitSchema(model).optional(),
10351037
});
1036-
return this.refineForSelectIncludeMutuallyExclusive(schema);
1038+
schema = this.refineForSelectIncludeMutuallyExclusive(schema);
1039+
schema = this.refineForSelectOmitMutuallyExclusive(schema);
1040+
return schema;
10371041
}
10381042

10391043
private makeUpdateManySchema(model: string) {
@@ -1046,23 +1050,26 @@ export class InputValidator<Schema extends SchemaDef> {
10461050

10471051
private makeUpdateManyAndReturnSchema(model: string) {
10481052
const base = this.makeUpdateManySchema(model);
1049-
const result = base.extend({
1053+
let schema: ZodSchema = base.extend({
10501054
select: this.makeSelectSchema(model).optional(),
10511055
omit: this.makeOmitSchema(model).optional(),
10521056
});
1053-
return this.refineForSelectOmitMutuallyExclusive(result);
1057+
schema = this.refineForSelectOmitMutuallyExclusive(schema);
1058+
return schema;
10541059
}
10551060

10561061
private makeUpsertSchema(model: string) {
1057-
const schema = z.strictObject({
1062+
let schema: ZodSchema = z.strictObject({
10581063
where: this.makeWhereSchema(model, true),
10591064
create: this.makeCreateDataSchema(model, false),
10601065
update: this.makeUpdateDataSchema(model),
10611066
select: this.makeSelectSchema(model).optional(),
10621067
include: this.makeIncludeSchema(model).optional(),
10631068
omit: this.makeOmitSchema(model).optional(),
10641069
});
1065-
return this.refineForSelectIncludeMutuallyExclusive(schema);
1070+
schema = this.refineForSelectIncludeMutuallyExclusive(schema);
1071+
schema = this.refineForSelectOmitMutuallyExclusive(schema);
1072+
return schema;
10661073
}
10671074

10681075
private makeUpdateDataSchema(model: string, withoutFields: string[] = [], withoutRelationFields = false) {
@@ -1166,12 +1173,14 @@ export class InputValidator<Schema extends SchemaDef> {
11661173
// #region Delete
11671174

11681175
private makeDeleteSchema(model: GetModels<Schema>) {
1169-
const schema = z.strictObject({
1176+
let schema: ZodSchema = z.strictObject({
11701177
where: this.makeWhereSchema(model, true),
11711178
select: this.makeSelectSchema(model).optional(),
11721179
include: this.makeIncludeSchema(model).optional(),
11731180
});
1174-
return this.refineForSelectIncludeMutuallyExclusive(schema);
1181+
schema = this.refineForSelectIncludeMutuallyExclusive(schema);
1182+
schema = this.refineForSelectOmitMutuallyExclusive(schema);
1183+
return schema;
11751184
}
11761185

11771186
private makeDeleteManySchema(model: GetModels<Schema>) {

packages/runtime/src/client/helpers/schema-db-pusher.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,19 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
2929
}
3030

3131
// sort models so that target of fk constraints are created first
32-
const sortedModels = this.sortModels(this.schema.models);
32+
const models = Object.values(this.schema.models).filter((m) => !m.isView);
33+
const sortedModels = this.sortModels(models);
3334
for (const modelDef of sortedModels) {
3435
const createTable = this.createModelTable(tx, modelDef);
3536
await createTable.execute();
3637
}
3738
});
3839
}
3940

40-
private sortModels(models: Record<string, ModelDef>): ModelDef[] {
41+
private sortModels(models: ModelDef[]): ModelDef[] {
4142
const graph: [ModelDef, ModelDef | undefined][] = [];
4243

43-
for (const model of Object.values(models)) {
44+
for (const model of models) {
4445
let added = false;
4546

4647
if (model.baseModel) {
Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
1+
/**
2+
* Reason code for policy rejection.
3+
*/
4+
export enum RejectedByPolicyReason {
5+
/**
6+
* Rejected because the operation is not allowed by policy.
7+
*/
8+
NO_ACCESS = 'no-access',
9+
10+
/**
11+
* Rejected because the result cannot be read back after mutation due to policy.
12+
*/
13+
CANNOT_READ_BACK = 'cannot-read-back',
14+
15+
/**
16+
* Other reasons.
17+
*/
18+
OTHER = 'other',
19+
}
20+
121
/**
222
* Error thrown when an operation is rejected by access policy.
323
*/
424
export class RejectedByPolicyError extends Error {
525
constructor(
626
public readonly model: string | undefined,
7-
public readonly reason?: string,
27+
public readonly reason: RejectedByPolicyReason = RejectedByPolicyReason.NO_ACCESS,
28+
message?: string,
829
) {
9-
super(reason ?? `Operation rejected by policy${model ? ': ' + model : ''}`);
30+
super(message ?? `Operation rejected by policy${model ? ': ' + model : ''}`);
1031
}
1132
}

packages/runtime/src/plugins/policy/policy-handler.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import type { ProceedKyselyQueryFunction } from '../../client/plugin';
3939
import { getManyToManyRelation, requireField, requireIdFields, requireModel } from '../../client/query-utils';
4040
import { ExpressionUtils, type BuiltinType, type Expression, type GetModels, type SchemaDef } from '../../schema';
4141
import { ColumnCollector } from './column-collector';
42-
import { RejectedByPolicyError } from './errors';
42+
import { RejectedByPolicyError, RejectedByPolicyReason } from './errors';
4343
import { ExpressionTransformer } from './expression-transformer';
4444
import type { Policy, PolicyOperation } from './types';
4545
import { buildIsFalse, conjunction, disjunction, falseNode, getTableName } from './utils';
@@ -66,7 +66,11 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
6666
) {
6767
if (!this.isCrudQueryNode(node)) {
6868
// non-CRUD queries are not allowed
69-
throw new RejectedByPolicyError(undefined, 'non-CRUD queries are not allowed');
69+
throw new RejectedByPolicyError(
70+
undefined,
71+
RejectedByPolicyReason.OTHER,
72+
'non-CRUD queries are not allowed',
73+
);
7074
}
7175

7276
if (!this.isMutationQueryNode(node)) {
@@ -106,7 +110,11 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
106110
} else {
107111
const readBackResult = await this.processReadBack(node, result, proceed);
108112
if (readBackResult.rows.length !== result.rows.length) {
109-
throw new RejectedByPolicyError(mutationModel, 'result is not allowed to be read back');
113+
throw new RejectedByPolicyError(
114+
mutationModel,
115+
RejectedByPolicyReason.CANNOT_READ_BACK,
116+
'result is not allowed to be read back',
117+
);
110118
}
111119
return readBackResult;
112120
}
@@ -335,12 +343,14 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
335343
if (!result.rows[0]?.$conditionA) {
336344
throw new RejectedByPolicyError(
337345
m2m.firstModel as GetModels<Schema>,
346+
RejectedByPolicyReason.CANNOT_READ_BACK,
338347
`many-to-many relation participant model "${m2m.firstModel}" not updatable`,
339348
);
340349
}
341350
if (!result.rows[0]?.$conditionB) {
342351
throw new RejectedByPolicyError(
343352
m2m.secondModel as GetModels<Schema>,
353+
RejectedByPolicyReason.NO_ACCESS,
344354
`many-to-many relation participant model "${m2m.secondModel}" not updatable`,
345355
);
346356
}

0 commit comments

Comments
 (0)