Skip to content

Commit be28f2e

Browse files
authored
fix: support check() policy function call in permission checkers (#1820)
1 parent daa3839 commit be28f2e

File tree

4 files changed

+213
-4
lines changed

4 files changed

+213
-4
lines changed

packages/runtime/src/enhancements/node/policy/policy-utils.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import deepmerge from 'deepmerge';
44
import { isPlainObject } from 'is-plain-object';
55
import { lowerCaseFirst } from 'lower-case-first';
6+
import traverse from 'traverse';
67
import { upperCaseFirst } from 'upper-case-first';
78
import { z, type ZodError, type ZodObject, type ZodSchema } from 'zod';
89
import { fromZodError } from 'zod-validation-error';
@@ -31,7 +32,15 @@ import { getVersion } from '../../../version';
3132
import type { InternalEnhancementOptions } from '../create-enhancement';
3233
import { Logger } from '../logger';
3334
import { QueryUtils } from '../query-utils';
34-
import type { EntityChecker, ModelPolicyDef, PermissionCheckerFunc, PolicyDef, PolicyFunc } from '../types';
35+
import type {
36+
DelegateConstraint,
37+
EntityChecker,
38+
ModelPolicyDef,
39+
PermissionCheckerFunc,
40+
PolicyDef,
41+
PolicyFunc,
42+
VariableConstraint,
43+
} from '../types';
3544
import { formatObject, prismaClientKnownRequestError } from '../utils';
3645

3746
/**
@@ -667,7 +676,47 @@ export class PolicyUtil extends QueryUtils {
667676
}
668677

669678
// call checker function
670-
return checker({ user: this.user });
679+
let result = checker({ user: this.user });
680+
681+
// the constraint may contain "delegate" ones that should be resolved
682+
// by evaluating the corresponding checker of the delegated models
683+
684+
const isVariableConstraint = (value: any): value is VariableConstraint => {
685+
return value && typeof value === 'object' && value.kind === 'variable';
686+
};
687+
688+
const isDelegateConstraint = (value: any): value is DelegateConstraint => {
689+
return value && typeof value === 'object' && value.kind === 'delegate';
690+
};
691+
692+
// here we prefix the constraint variables coming from delegated checkers
693+
// with the relation field name to avoid conflicts
694+
const prefixConstraintVariables = (constraint: unknown, prefix: string) => {
695+
return traverse(constraint).map(function (value) {
696+
if (isVariableConstraint(value)) {
697+
this.update(
698+
{
699+
...value,
700+
name: `${prefix}${value.name}`,
701+
},
702+
true
703+
);
704+
}
705+
});
706+
};
707+
708+
// eslint-disable-next-line @typescript-eslint/no-this-alias
709+
const that = this;
710+
result = traverse(result).forEach(function (value) {
711+
if (isDelegateConstraint(value)) {
712+
const { model: delegateModel, relation, operation: delegateOp } = value;
713+
let newValue = that.getCheckerConstraint(delegateModel, delegateOp ?? operation);
714+
newValue = prefixConstraintVariables(newValue, `${relation}.`);
715+
this.update(newValue, true);
716+
}
717+
});
718+
719+
return result;
671720
}
672721

673722
//#endregion

packages/runtime/src/enhancements/node/types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export interface CommonEnhancementOptions {
1818
prismaModule?: any;
1919
}
2020

21+
/**
22+
* CRUD operations
23+
*/
24+
export type CRUD = 'create' | 'read' | 'update' | 'delete';
25+
2126
/**
2227
* Function for getting policy guard with a given context
2328
*/
@@ -74,14 +79,26 @@ export type LogicalConstraint = {
7479
children: PermissionCheckerConstraint[];
7580
};
7681

82+
/**
83+
* Constraint delegated to another model through `check()` function call
84+
* on a relation field.
85+
*/
86+
export type DelegateConstraint = {
87+
kind: 'delegate';
88+
model: string;
89+
relation: string;
90+
operation?: CRUD;
91+
};
92+
7793
/**
7894
* Operation allowability checking constraint
7995
*/
8096
export type PermissionCheckerConstraint =
8197
| ValueConstraint
8298
| VariableConstraint
8399
| ComparisonConstraint
84-
| LogicalConstraint;
100+
| LogicalConstraint
101+
| DelegateConstraint;
85102

86103
/**
87104
* Policy definition

packages/schema/src/plugins/enhancer/policy/constraint-transformer.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
PluginError,
3+
getLiteral,
24
getRelationKeyPairs,
35
isAuthInvocation,
46
isDataModelFieldReference,
@@ -7,9 +9,11 @@ import {
79
import {
810
BinaryExpr,
911
BooleanLiteral,
12+
DataModel,
1013
DataModelField,
1114
Expression,
1215
ExpressionType,
16+
InvocationExpr,
1317
LiteralExpr,
1418
MemberAccessExpr,
1519
NumberLiteral,
@@ -27,6 +31,8 @@ import {
2731
isUnaryExpr,
2832
} from '@zenstackhq/sdk/ast';
2933
import { P, match } from 'ts-pattern';
34+
import { name } from '..';
35+
import { isCheckInvocation } from '../../../utils/ast-utils';
3036

3137
/**
3238
* Options for {@link ConstraintTransformer}.
@@ -107,6 +113,8 @@ export class ConstraintTransformer {
107113
.when(isReferenceExpr, (expr) => this.transformReference(expr))
108114
// top-level boolean member access expr
109115
.when(isMemberAccessExpr, (expr) => this.transformMemberAccess(expr))
116+
// `check()` invocation on a relation field
117+
.when(isCheckInvocation, (expr) => this.transformCheckInvocation(expr as InvocationExpr))
110118
.otherwise(() => this.nextVar())
111119
);
112120
}
@@ -259,6 +267,30 @@ export class ConstraintTransformer {
259267
return undefined;
260268
}
261269

270+
private transformCheckInvocation(expr: InvocationExpr) {
271+
// transform `check()` invocation to a special "delegate" constraint kind
272+
// to be evaluated at runtime
273+
274+
const field = expr.args[0].value as ReferenceExpr;
275+
if (!field) {
276+
throw new PluginError(name, 'Invalid check invocation');
277+
}
278+
const fieldType = field.$resolvedType?.decl as DataModel;
279+
280+
let operation: string | undefined = undefined;
281+
if (expr.args[1]) {
282+
operation = getLiteral<string>(expr.args[1].value);
283+
}
284+
285+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
286+
const result: any = { kind: 'delegate', model: fieldType.name, relation: field.target.$refText };
287+
if (operation) {
288+
// operation can be explicitly specified or inferred from the context
289+
result.operation = operation;
290+
}
291+
return JSON.stringify(result);
292+
}
293+
262294
// normalize `auth()` access undefined value to null
263295
private normalizeToNull(expr: string) {
264296
return `(${expr} ?? null)`;

tests/integration/tests/enhancements/with-policy/checker.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ describe('Permission checker', () => {
357357
await expect(db.model.check({ operation: 'update', where: { x: 1, y: 1 } })).toResolveFalsy();
358358
});
359359

360-
it('field condition unsolvable', async () => {
360+
it('field condition unsatisfiable', async () => {
361361
const { enhance } = await load(
362362
`
363363
model Model {
@@ -649,4 +649,115 @@ describe('Permission checker', () => {
649649
await expect(db.model.check({ operation: 'read', where: { value: 1 } })).toResolveTruthy();
650650
await expect(db.model.check({ operation: 'read', where: { value: 2 } })).toResolveTruthy();
651651
});
652+
653+
it('supports policy delegation simple', async () => {
654+
const { enhance } = await load(
655+
`
656+
model User {
657+
id Int @id @default(autoincrement())
658+
foo Foo[]
659+
}
660+
661+
model Foo {
662+
id Int @id @default(autoincrement())
663+
owner User @relation(fields: [ownerId], references: [id])
664+
ownerId Int
665+
model Model?
666+
@@allow('read', auth().id == ownerId)
667+
@@allow('create', auth().id != ownerId)
668+
@@allow('update', auth() == owner)
669+
}
670+
671+
model Model {
672+
id Int @id @default(autoincrement())
673+
foo Foo @relation(fields: [fooId], references: [id])
674+
fooId Int @unique
675+
@@allow('all', check(foo))
676+
}
677+
`,
678+
{ preserveTsFiles: true }
679+
);
680+
681+
await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy();
682+
await expect(enhance({ id: 1 }).model.check({ operation: 'read' })).toResolveTruthy();
683+
684+
await expect(enhance().model.check({ operation: 'create' })).toResolveFalsy();
685+
await expect(enhance({ id: 1 }).model.check({ operation: 'create' })).toResolveTruthy();
686+
687+
await expect(enhance().model.check({ operation: 'update' })).toResolveFalsy();
688+
await expect(enhance({ id: 1 }).model.check({ operation: 'update' })).toResolveTruthy();
689+
690+
await expect(enhance().model.check({ operation: 'delete' })).toResolveFalsy();
691+
await expect(enhance({ id: 1 }).model.check({ operation: 'delete' })).toResolveFalsy();
692+
});
693+
694+
it('supports policy delegation explicit', async () => {
695+
const { enhance } = await load(
696+
`
697+
model Foo {
698+
id Int @id @default(autoincrement())
699+
model Model?
700+
@@allow('all', true)
701+
@@deny('update', true)
702+
}
703+
704+
model Model {
705+
id Int @id @default(autoincrement())
706+
foo Foo @relation(fields: [fooId], references: [id])
707+
fooId Int @unique
708+
@@allow('read', check(foo, 'update'))
709+
}
710+
`,
711+
{ preserveTsFiles: true }
712+
);
713+
714+
await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy();
715+
});
716+
717+
it('supports policy delegation combined', async () => {
718+
const { enhance } = await load(
719+
`
720+
model User {
721+
id Int @id @default(autoincrement())
722+
foo Foo[]
723+
}
724+
725+
model Foo {
726+
id Int @id @default(autoincrement())
727+
owner User @relation(fields: [ownerId], references: [id])
728+
ownerId Int
729+
model Model?
730+
@@allow('read', auth().id == ownerId)
731+
@@allow('create', auth().id != ownerId)
732+
@@allow('update', auth() == owner)
733+
}
734+
735+
model Model {
736+
id Int @id @default(autoincrement())
737+
foo Foo @relation(fields: [fooId], references: [id])
738+
fooId Int @unique
739+
value Int
740+
@@allow('all', check(foo) && value > 0)
741+
@@deny('update', check(foo) && value == 1)
742+
}
743+
`,
744+
{ preserveTsFiles: true }
745+
);
746+
747+
await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy();
748+
await expect(enhance({ id: 1 }).model.check({ operation: 'read' })).toResolveTruthy();
749+
await expect(enhance({ id: 1 }).model.check({ operation: 'read', where: { value: 1 } })).toResolveTruthy();
750+
await expect(enhance({ id: 1 }).model.check({ operation: 'read', where: { value: 0 } })).toResolveFalsy();
751+
752+
await expect(enhance().model.check({ operation: 'create' })).toResolveFalsy();
753+
await expect(enhance({ id: 1 }).model.check({ operation: 'create' })).toResolveTruthy();
754+
await expect(enhance({ id: 1 }).model.check({ operation: 'create', where: { value: 1 } })).toResolveTruthy();
755+
await expect(enhance({ id: 1 }).model.check({ operation: 'create', where: { value: 0 } })).toResolveFalsy();
756+
757+
await expect(enhance().model.check({ operation: 'update' })).toResolveFalsy();
758+
await expect(enhance({ id: 1 }).model.check({ operation: 'update' })).toResolveTruthy();
759+
await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 2 } })).toResolveTruthy();
760+
await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 0 } })).toResolveFalsy();
761+
await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 1 } })).toResolveFalsy();
762+
});
652763
});

0 commit comments

Comments
 (0)