Skip to content

Commit 2ef2da0

Browse files
committed
more tests
1 parent 19fea2c commit 2ef2da0

File tree

4 files changed

+147
-28
lines changed

4 files changed

+147
-28
lines changed

packages/runtime/src/enhancements/policy/constraint-solver.ts

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Logic, { Formula } from 'logic-solver';
1+
import Logic from 'logic-solver';
22
import { match } from 'ts-pattern';
33
import type {
44
CheckerConstraint,
@@ -9,25 +9,41 @@ import type {
99
VariableConstraint,
1010
} from '../types';
1111

12+
/**
13+
* A boolean constraint solver based on `logic-solver`.
14+
*/
1215
export class ConstraintSolver {
16+
// a table for internalizing string literals
1317
private stringTable: string[] = [];
14-
private variables: Map<string, Formula> = new Map<string, Formula>();
1518

16-
solve(constraint: CheckerConstraint): boolean {
19+
// a map for storing variable names and their corresponding formulas
20+
private variables: Map<string, Logic.Formula> = new Map<string, Logic.Formula>();
21+
22+
/**
23+
* Check the satisfiability of the given constraint.
24+
*/
25+
checkSat(constraint: CheckerConstraint): boolean {
26+
// reset state
1727
this.stringTable = [];
18-
this.variables = new Map<string, Formula>();
28+
this.variables = new Map<string, Logic.Formula>();
1929

30+
// convert the constraint to a "logic-solver" formula
2031
const formula = this.buildFormula(constraint);
32+
33+
// solve the formula
2134
const solver = new Logic.Solver();
2235
solver.require(formula);
23-
const solution = solver.solve();
24-
if (solution) {
25-
console.log('Solution:');
26-
this.variables.forEach((v, k) => console.log(`\t${k}=${solution?.evaluate(v)}`));
27-
} else {
28-
console.log('No solution');
29-
}
30-
return !!solution;
36+
37+
// DEBUG:
38+
// const solution = solver.solve();
39+
// if (solution) {
40+
// console.log('Solution:');
41+
// this.variables.forEach((v, k) => console.log(`\t${k}=${solution?.evaluate(v)}`));
42+
// } else {
43+
// console.log('No solution');
44+
// }
45+
46+
return !!solver.solve();
3147
}
3248

3349
private buildFormula(constraint: CheckerConstraint): Logic.Formula {
@@ -84,12 +100,15 @@ export class ConstraintSolver {
84100
.exhaustive();
85101
}
86102

87-
buildVariableFormula(constraint: VariableConstraint) {
88-
return match(constraint.type)
89-
.with('boolean', () => this.booleanVariable(constraint.name))
90-
.with('number', () => this.intVariable(constraint.name))
91-
.with('string', () => this.intVariable(constraint.name))
92-
.exhaustive();
103+
private buildVariableFormula(constraint: VariableConstraint) {
104+
return (
105+
match(constraint.type)
106+
.with('boolean', () => this.booleanVariable(constraint.name))
107+
.with('number', () => this.intVariable(constraint.name))
108+
// strings are internalized and represented by their indices
109+
.with('string', () => this.intVariable(constraint.name))
110+
.exhaustive()
111+
);
93112
}
94113

95114
private buildValueFormula(constraint: ValueConstraint) {
@@ -105,6 +124,7 @@ export class ConstraintSolver {
105124
.when(
106125
(v): v is string => typeof v === 'string',
107126
(v) => {
127+
// internalize the string and use its index as formula representation
108128
const index = this.stringTable.indexOf(v);
109129
if (index === -1) {
110130
this.stringTable.push(v);
@@ -132,12 +152,15 @@ export class ConstraintSolver {
132152
if (left.type !== right.type) {
133153
throw new Error(`Type mismatch in equality constraint: ${JSON.stringify(left)}, ${JSON.stringify(right)}`);
134154
}
135-
const leftConstraint = this.buildFormula(left);
136-
const rightConstraint = this.buildFormula(right);
155+
156+
const leftFormula = this.buildFormula(left);
157+
const rightFormula = this.buildFormula(right);
137158
if (left.type === 'boolean' && right.type === 'boolean') {
138-
return Logic.equiv(leftConstraint, rightConstraint);
159+
// logical equivalence
160+
return Logic.equiv(leftFormula, rightFormula);
139161
} else {
140-
return Logic.equalBits(leftConstraint, rightConstraint);
162+
// integer equality
163+
return Logic.equalBits(leftFormula, rightFormula);
141164
}
142165
}
143166

@@ -146,8 +169,6 @@ export class ConstraintSolver {
146169
right: ComparisonTerm,
147170
func: (left: Logic.Formula, right: Logic.Formula) => Logic.Formula
148171
) {
149-
const leftConstraint = this.buildFormula(left);
150-
const rightConstraint = this.buildFormula(right);
151-
return func(leftConstraint, rightConstraint);
172+
return func(this.buildFormula(left), this.buildFormula(right));
152173
}
153174
}

packages/runtime/src/enhancements/policy/handler.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,6 +1451,8 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
14511451
}
14521452

14531453
if (fieldValues) {
1454+
// combine runtime filters with generated constraints
1455+
14541456
const extraConstraints: CheckerConstraint[] = [];
14551457
for (const [field, value] of Object.entries(fieldValues)) {
14561458
if (value === undefined) {
@@ -1463,12 +1465,14 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
14631465

14641466
const fieldInfo = requireField(this.modelMeta, this.model, field);
14651467

1468+
// relation and array fields are not supported
14661469
if (fieldInfo.isDataModel || fieldInfo.isArray) {
14671470
throw new Error(
14681471
`Providing filter for field "${field}" is not supported. Only scalar fields are allowed.`
14691472
);
14701473
}
14711474

1475+
// map field type to constraint type
14721476
const fieldType = match<string, 'number' | 'string' | 'boolean'>(fieldInfo.type)
14731477
.with(P.union('Int', 'BigInt', 'Float', 'Decimal'), () => 'number')
14741478
.with('String', () => 'string')
@@ -1479,13 +1483,24 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
14791483
);
14801484
});
14811485

1486+
// check value type
14821487
const valueType = typeof value;
14831488
if (valueType !== 'number' && valueType !== 'string' && valueType !== 'boolean') {
14841489
throw new Error(
1485-
`Invalid value for field "${field}". Only number, string, boolean, or null is allowed.`
1490+
`Invalid value type for field "${field}". Only number, string or boolean is allowed.`
14861491
);
14871492
}
14881493

1494+
if (fieldType !== valueType) {
1495+
throw new Error(`Invalid value type for field "${field}". Expected "${fieldType}".`);
1496+
}
1497+
1498+
// check number validity
1499+
if (typeof value === 'number' && (!Number.isInteger(value) || value < 0)) {
1500+
throw new Error(`Invalid value for field "${field}". Only non-negative integers are allowed.`);
1501+
}
1502+
1503+
// build a constraint
14891504
extraConstraints.push({
14901505
kind: 'eq',
14911506
left: { kind: 'variable', name: field, type: fieldType },
@@ -1494,11 +1509,13 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
14941509
}
14951510

14961511
if (extraConstraints.length > 0) {
1512+
// combine the constraints
14971513
constraint = { kind: 'and', children: [constraint, ...extraConstraints] };
14981514
}
14991515
}
15001516

1501-
return new ConstraintSolver().solve(constraint);
1517+
// check satisfiability
1518+
return new ConstraintSolver().checkSat(constraint);
15021519
}
15031520

15041521
//#endregion

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,15 @@ export class ConstraintTransformer {
9090

9191
private transformLiteral(expr: LiteralExpr) {
9292
return match(expr.$type)
93-
.with(NumberLiteral, () => this.value(expr.value.toString(), 'number'))
93+
.with(NumberLiteral, () => {
94+
const parsed = parseFloat(expr.value as string);
95+
if (isNaN(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
96+
// only non-negative integers are supported, for other cases,
97+
// transform into a free variable
98+
return this.nextVar('number');
99+
}
100+
return this.value(expr.value.toString(), 'number');
101+
})
94102
.with(StringLiteral, () => this.value(`'${expr.value}'`, 'string'))
95103
.with(BooleanLiteral, () => this.value(expr.value.toString(), 'boolean'))
96104
.exhaustive();

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,77 @@ describe('Permission checker', () => {
355355
}
356356
);
357357
});
358+
359+
it('invalid filter', async () => {
360+
const { enhance } = await loadSchema(
361+
`
362+
model Model {
363+
id Int @id @default(autoincrement())
364+
value Int
365+
foo Foo?
366+
d DateTime
367+
368+
@@allow('read', value == 1)
369+
}
370+
371+
model Foo {
372+
id Int @id @default(autoincrement())
373+
x Int
374+
model Model @relation(fields: [modelId], references: [id])
375+
modelId Int @unique
376+
}
377+
`
378+
);
379+
380+
const db = enhance();
381+
await expect(db.model.check('read', { foo: { x: 1 } })).rejects.toThrow(
382+
`Providing filter for field "foo" is not supported. Only scalar fields are allowed.`
383+
);
384+
await expect(db.model.check('read', { d: new Date() })).rejects.toThrow(
385+
`Providing filter for field "d" is not supported. Only number, string, and boolean fields are allowed.`
386+
);
387+
await expect(db.model.check('read', { value: null })).rejects.toThrow(
388+
`Using "null" as filter value is not supported yet`
389+
);
390+
await expect(db.model.check('read', { value: {} })).rejects.toThrow(
391+
'Invalid value type for field "value". Only number, string or boolean is allowed.'
392+
);
393+
await expect(db.model.check('read', { value: 'abc' })).rejects.toThrow(
394+
'Invalid value type for field "value". Expected "number"'
395+
);
396+
await expect(db.model.check('read', { value: -1 })).rejects.toThrow(
397+
'Invalid value for field "value". Only non-negative integers are allowed.'
398+
);
399+
});
400+
401+
it('float field ignored', async () => {
402+
const { enhance } = await loadSchema(
403+
`
404+
model Model {
405+
id Int @id @default(autoincrement())
406+
value Float
407+
@@allow('read', value == 1.1)
408+
}
409+
`
410+
);
411+
const db = enhance();
412+
await expect(db.model.check('read')).toResolveTruthy();
413+
await expect(db.model.check('read', { value: 1 })).toResolveTruthy();
414+
});
415+
416+
it('float value ignored', async () => {
417+
const { enhance } = await loadSchema(
418+
`
419+
model Model {
420+
id Int @id @default(autoincrement())
421+
value Int
422+
@@allow('read', value > 1.1)
423+
}
424+
`
425+
);
426+
const db = enhance();
427+
// await expect(db.model.check('read')).toResolveTruthy();
428+
await expect(db.model.check('read', { value: 1 })).toResolveTruthy();
429+
await expect(db.model.check('read', { value: 2 })).toResolveTruthy();
430+
});
358431
});

0 commit comments

Comments
 (0)