Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: support check() policy function call in permission checkers #1820

Merged
merged 1 commit into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions packages/runtime/src/enhancements/node/policy/policy-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import deepmerge from 'deepmerge';
import { isPlainObject } from 'is-plain-object';
import { lowerCaseFirst } from 'lower-case-first';
import traverse from 'traverse';
import { upperCaseFirst } from 'upper-case-first';
import { z, type ZodError, type ZodObject, type ZodSchema } from 'zod';
import { fromZodError } from 'zod-validation-error';
Expand Down Expand Up @@ -31,7 +32,15 @@ import { getVersion } from '../../../version';
import type { InternalEnhancementOptions } from '../create-enhancement';
import { Logger } from '../logger';
import { QueryUtils } from '../query-utils';
import type { EntityChecker, ModelPolicyDef, PermissionCheckerFunc, PolicyDef, PolicyFunc } from '../types';
import type {
DelegateConstraint,
EntityChecker,
ModelPolicyDef,
PermissionCheckerFunc,
PolicyDef,
PolicyFunc,
VariableConstraint,
} from '../types';
import { formatObject, prismaClientKnownRequestError } from '../utils';

/**
Expand Down Expand Up @@ -667,7 +676,47 @@ export class PolicyUtil extends QueryUtils {
}

// call checker function
return checker({ user: this.user });
let result = checker({ user: this.user });

// the constraint may contain "delegate" ones that should be resolved
// by evaluating the corresponding checker of the delegated models

const isVariableConstraint = (value: any): value is VariableConstraint => {
return value && typeof value === 'object' && value.kind === 'variable';
};

const isDelegateConstraint = (value: any): value is DelegateConstraint => {
return value && typeof value === 'object' && value.kind === 'delegate';
};

// here we prefix the constraint variables coming from delegated checkers
// with the relation field name to avoid conflicts
const prefixConstraintVariables = (constraint: unknown, prefix: string) => {
return traverse(constraint).map(function (value) {
if (isVariableConstraint(value)) {
this.update(
{
...value,
name: `${prefix}${value.name}`,
},
true
);
}
});
};

// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
result = traverse(result).forEach(function (value) {
if (isDelegateConstraint(value)) {
const { model: delegateModel, relation, operation: delegateOp } = value;
let newValue = that.getCheckerConstraint(delegateModel, delegateOp ?? operation);
newValue = prefixConstraintVariables(newValue, `${relation}.`);
this.update(newValue, true);
}
});
ymc9 marked this conversation as resolved.
Show resolved Hide resolved

return result;
}

//#endregion
Expand Down
19 changes: 18 additions & 1 deletion packages/runtime/src/enhancements/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface CommonEnhancementOptions {
prismaModule?: any;
}

/**
* CRUD operations
*/
export type CRUD = 'create' | 'read' | 'update' | 'delete';

/**
* Function for getting policy guard with a given context
*/
Expand Down Expand Up @@ -74,14 +79,26 @@ export type LogicalConstraint = {
children: PermissionCheckerConstraint[];
};

/**
* Constraint delegated to another model through `check()` function call
* on a relation field.
*/
export type DelegateConstraint = {
kind: 'delegate';
model: string;
relation: string;
operation?: CRUD;
};

/**
* Operation allowability checking constraint
*/
export type PermissionCheckerConstraint =
| ValueConstraint
| VariableConstraint
| ComparisonConstraint
| LogicalConstraint;
| LogicalConstraint
| DelegateConstraint;

/**
* Policy definition
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
PluginError,
getLiteral,
getRelationKeyPairs,
isAuthInvocation,
isDataModelFieldReference,
Expand All @@ -7,9 +9,11 @@ import {
import {
BinaryExpr,
BooleanLiteral,
DataModel,
DataModelField,
Expression,
ExpressionType,
InvocationExpr,
LiteralExpr,
MemberAccessExpr,
NumberLiteral,
Expand All @@ -27,6 +31,8 @@ import {
isUnaryExpr,
} from '@zenstackhq/sdk/ast';
import { P, match } from 'ts-pattern';
import { name } from '..';
import { isCheckInvocation } from '../../../utils/ast-utils';
ymc9 marked this conversation as resolved.
Show resolved Hide resolved

/**
* Options for {@link ConstraintTransformer}.
Expand Down Expand Up @@ -107,6 +113,8 @@ export class ConstraintTransformer {
.when(isReferenceExpr, (expr) => this.transformReference(expr))
// top-level boolean member access expr
.when(isMemberAccessExpr, (expr) => this.transformMemberAccess(expr))
// `check()` invocation on a relation field
.when(isCheckInvocation, (expr) => this.transformCheckInvocation(expr as InvocationExpr))
.otherwise(() => this.nextVar())
);
}
Expand Down Expand Up @@ -259,6 +267,30 @@ export class ConstraintTransformer {
return undefined;
}

private transformCheckInvocation(expr: InvocationExpr) {
// transform `check()` invocation to a special "delegate" constraint kind
// to be evaluated at runtime

const field = expr.args[0].value as ReferenceExpr;
if (!field) {
throw new PluginError(name, 'Invalid check invocation');
}
ymc9 marked this conversation as resolved.
Show resolved Hide resolved
const fieldType = field.$resolvedType?.decl as DataModel;

ymc9 marked this conversation as resolved.
Show resolved Hide resolved
let operation: string | undefined = undefined;
if (expr.args[1]) {
operation = getLiteral<string>(expr.args[1].value);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = { kind: 'delegate', model: fieldType.name, relation: field.target.$refText };
ymc9 marked this conversation as resolved.
Show resolved Hide resolved
if (operation) {
// operation can be explicitly specified or inferred from the context
result.operation = operation;
}
return JSON.stringify(result);
}

// normalize `auth()` access undefined value to null
private normalizeToNull(expr: string) {
return `(${expr} ?? null)`;
Expand Down
113 changes: 112 additions & 1 deletion tests/integration/tests/enhancements/with-policy/checker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ describe('Permission checker', () => {
await expect(db.model.check({ operation: 'update', where: { x: 1, y: 1 } })).toResolveFalsy();
});

it('field condition unsolvable', async () => {
it('field condition unsatisfiable', async () => {
const { enhance } = await load(
`
model Model {
Expand Down Expand Up @@ -649,4 +649,115 @@ describe('Permission checker', () => {
await expect(db.model.check({ operation: 'read', where: { value: 1 } })).toResolveTruthy();
await expect(db.model.check({ operation: 'read', where: { value: 2 } })).toResolveTruthy();
});

it('supports policy delegation simple', async () => {
const { enhance } = await load(
`
model User {
id Int @id @default(autoincrement())
foo Foo[]
}

model Foo {
id Int @id @default(autoincrement())
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
model Model?
@@allow('read', auth().id == ownerId)
@@allow('create', auth().id != ownerId)
@@allow('update', auth() == owner)
}

model Model {
id Int @id @default(autoincrement())
foo Foo @relation(fields: [fooId], references: [id])
fooId Int @unique
@@allow('all', check(foo))
}
`,
{ preserveTsFiles: true }
);

await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'read' })).toResolveTruthy();

await expect(enhance().model.check({ operation: 'create' })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'create' })).toResolveTruthy();

await expect(enhance().model.check({ operation: 'update' })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'update' })).toResolveTruthy();

await expect(enhance().model.check({ operation: 'delete' })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'delete' })).toResolveFalsy();
});

it('supports policy delegation explicit', async () => {
const { enhance } = await load(
`
model Foo {
id Int @id @default(autoincrement())
model Model?
@@allow('all', true)
@@deny('update', true)
}

model Model {
id Int @id @default(autoincrement())
foo Foo @relation(fields: [fooId], references: [id])
fooId Int @unique
@@allow('read', check(foo, 'update'))
}
`,
{ preserveTsFiles: true }
);

await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy();
});

it('supports policy delegation combined', async () => {
const { enhance } = await load(
`
model User {
id Int @id @default(autoincrement())
foo Foo[]
}

model Foo {
id Int @id @default(autoincrement())
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
model Model?
@@allow('read', auth().id == ownerId)
@@allow('create', auth().id != ownerId)
@@allow('update', auth() == owner)
}

model Model {
id Int @id @default(autoincrement())
foo Foo @relation(fields: [fooId], references: [id])
fooId Int @unique
value Int
@@allow('all', check(foo) && value > 0)
@@deny('update', check(foo) && value == 1)
}
`,
{ preserveTsFiles: true }
);

await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'read' })).toResolveTruthy();
await expect(enhance({ id: 1 }).model.check({ operation: 'read', where: { value: 1 } })).toResolveTruthy();
await expect(enhance({ id: 1 }).model.check({ operation: 'read', where: { value: 0 } })).toResolveFalsy();

await expect(enhance().model.check({ operation: 'create' })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'create' })).toResolveTruthy();
await expect(enhance({ id: 1 }).model.check({ operation: 'create', where: { value: 1 } })).toResolveTruthy();
await expect(enhance({ id: 1 }).model.check({ operation: 'create', where: { value: 0 } })).toResolveFalsy();

await expect(enhance().model.check({ operation: 'update' })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'update' })).toResolveTruthy();
await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 2 } })).toResolveTruthy();
await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 0 } })).toResolveFalsy();
await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 1 } })).toResolveFalsy();
});
});
Loading