Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 6 additions & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"types": "./enhancements/index.d.ts",
"default": "./enhancements/index.js"
},
"./constraint-solver": {
"types": "./constraint-solver.d.ts",
"default": "./constraint-solver.js"
},
"./zod": {
"types": "./zod/index.d.ts",
"default": "./zod/index.js"
Expand Down Expand Up @@ -79,12 +83,14 @@
"decimal.js": "^10.4.2",
"deepcopy": "^2.1.0",
"deepmerge": "^4.3.1",
"logic-solver": "^2.0.1",
"lower-case-first": "^2.0.2",
"pluralize": "^8.0.0",
"safe-json-stringify": "^1.2.0",
"semver": "^7.5.2",
"superjson": "^1.11.0",
"tiny-invariant": "^1.3.1",
"ts-pattern": "^4.3.0",
"tslib": "^2.4.1",
"upper-case-first": "^2.0.2",
"uuid": "^9.0.0",
Expand Down
174 changes: 174 additions & 0 deletions packages/runtime/src/enhancements/policy/constraint-solver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import Logic from 'logic-solver';
import { match } from 'ts-pattern';
import type {
CheckerConstraint,
ComparisonConstraint,
ComparisonTerm,
LogicalConstraint,
ValueConstraint,
VariableConstraint,
} from '../types';

/**
* A boolean constraint solver based on `logic-solver`. Only boolean and integer types are supported.
*/
export class ConstraintSolver {
// a table for internalizing string literals
private stringTable: string[] = [];

// a map for storing variable names and their corresponding formulas
private variables: Map<string, Logic.Formula> = new Map<string, Logic.Formula>();

/**
* Check the satisfiability of the given constraint.
*/
checkSat(constraint: CheckerConstraint): boolean {
// reset state
this.stringTable = [];
this.variables = new Map<string, Logic.Formula>();

// convert the constraint to a "logic-solver" formula
const formula = this.buildFormula(constraint);

// solve the formula
const solver = new Logic.Solver();
solver.require(formula);

// DEBUG:
// const solution = solver.solve();
// if (solution) {
// console.log('Solution:');
// this.variables.forEach((v, k) => console.log(`\t${k}=${solution?.evaluate(v)}`));
// } else {
// console.log('No solution');
// }

return !!solver.solve();
}

private buildFormula(constraint: CheckerConstraint): Logic.Formula {
return match(constraint)
.when(
(c): c is ValueConstraint => c.kind === 'value',
(c) => this.buildValueFormula(c)
)
.when(
(c): c is VariableConstraint => c.kind === 'variable',
(c) => this.buildVariableFormula(c)
)
.when(
(c): c is ComparisonConstraint => ['eq', 'gt', 'gte', 'lt', 'lte'].includes(c.kind),
(c) => this.buildComparisonFormula(c)
)
.when(
(c): c is LogicalConstraint => ['and', 'or', 'not'].includes(c.kind),
(c) => this.buildLogicalFormula(c)
)
.otherwise(() => {
throw new Error(`Unsupported constraint format: ${JSON.stringify(constraint)}`);
});
}

private buildLogicalFormula(constraint: LogicalConstraint) {
return match(constraint.kind)
.with('and', () => Logic.and(...constraint.children.map((c) => this.buildFormula(c))))
.with('or', () => Logic.or(...constraint.children.map((c) => this.buildFormula(c))))
.with('not', () => {
if (constraint.children.length !== 1) {
throw new Error('"not" constraint must have exactly one child');
}
return Logic.not(this.buildFormula(constraint.children[0]));
})
.exhaustive();
}

private buildComparisonFormula(constraint: ComparisonConstraint) {
return match(constraint.kind)
.with('eq', () => this.transformEquality(constraint.left, constraint.right))
.with('gt', () =>
this.transformComparison(constraint.left, constraint.right, (l, r) => Logic.greaterThan(l, r))
)
.with('gte', () =>
this.transformComparison(constraint.left, constraint.right, (l, r) => Logic.greaterThanOrEqual(l, r))
)
.with('lt', () =>
this.transformComparison(constraint.left, constraint.right, (l, r) => Logic.lessThan(l, r))
)
.with('lte', () =>
this.transformComparison(constraint.left, constraint.right, (l, r) => Logic.lessThanOrEqual(l, r))
)
.exhaustive();
}

private buildVariableFormula(constraint: VariableConstraint) {
return (
match(constraint.type)
.with('boolean', () => this.booleanVariable(constraint.name))
.with('number', () => this.intVariable(constraint.name))
// strings are internalized and represented by their indices
.with('string', () => this.intVariable(constraint.name))
.exhaustive()
);
}

private buildValueFormula(constraint: ValueConstraint) {
return match(constraint.value)
.when(
(v): v is boolean => typeof v === 'boolean',
(v) => (v === true ? Logic.TRUE : Logic.FALSE)
)
.when(
(v): v is number => typeof v === 'number',
(v) => Logic.constantBits(v)
)
.when(
(v): v is string => typeof v === 'string',
(v) => {
// internalize the string and use its index as formula representation
const index = this.stringTable.indexOf(v);
if (index === -1) {
this.stringTable.push(v);
return Logic.constantBits(this.stringTable.length - 1);
} else {
return Logic.constantBits(index);
}
}
)
.exhaustive();
}

private booleanVariable(name: string) {
this.variables.set(name, name);
return name;
}

private intVariable(name: string) {
const r = Logic.variableBits(name, 32);
this.variables.set(name, r);
return r;
}

private transformEquality(left: ComparisonTerm, right: ComparisonTerm) {
if (left.type !== right.type) {
throw new Error(`Type mismatch in equality constraint: ${JSON.stringify(left)}, ${JSON.stringify(right)}`);
}

const leftFormula = this.buildFormula(left);
const rightFormula = this.buildFormula(right);
if (left.type === 'boolean' && right.type === 'boolean') {
// logical equivalence
return Logic.equiv(leftFormula, rightFormula);
} else {
// integer equality
return Logic.equalBits(leftFormula, rightFormula);
}
}

private transformComparison(
left: ComparisonTerm,
right: ComparisonTerm,
func: (left: Logic.Formula, right: Logic.Formula) => Logic.Formula
) {
return func(this.buildFormula(left), this.buildFormula(right));
}
}
91 changes: 90 additions & 1 deletion packages/runtime/src/enhancements/policy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { lowerCaseFirst } from 'lower-case-first';
import invariant from 'tiny-invariant';
import { P, match } from 'ts-pattern';
import { upperCaseFirst } from 'upper-case-first';
import { fromZodError } from 'zod-validation-error';
import { CrudFailureReason } from '../../constants';
Expand All @@ -16,13 +17,15 @@ import {
type FieldInfo,
type ModelMeta,
} from '../../cross';
import { PolicyOperationKind, type CrudContract, type DbClientContract } from '../../types';
import { PolicyCrudKind, PolicyOperationKind, type CrudContract, type DbClientContract } from '../../types';
import type { EnhancementContext, InternalEnhancementOptions } from '../create-enhancement';
import { Logger } from '../logger';
import { createDeferredPromise, createFluentPromise } from '../promise';
import { PrismaProxyHandler } from '../proxy';
import { QueryUtils } from '../query-utils';
import type { CheckerConstraint } from '../types';
import { clone, formatObject, isUnsafeMutate, prismaClientValidationError } from '../utils';
import { ConstraintSolver } from './constraint-solver';
import { PolicyUtil } from './policy-utils';

// a record for post-write policy check
Expand Down Expand Up @@ -1436,6 +1439,92 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr

//#endregion

//#region Check

/**
* Checks if the given operation is possibly allowed by the policy, without querying the database.
* @param operation The CRUD operation.
* @param fieldValues Extra field value filters to be combined with the policy constraints.
*/
async check(
operation: PolicyCrudKind,
fieldValues?: Record<string, number | string | boolean | null>
): Promise<boolean> {
let constraint = this.policyUtils.getCheckerConstraint(this.model, operation);
if (typeof constraint === 'boolean') {
return constraint;
}

if (fieldValues) {
// combine runtime filters with generated constraints

const extraConstraints: CheckerConstraint[] = [];
for (const [field, value] of Object.entries(fieldValues)) {
if (value === undefined) {
continue;
}

if (value === null) {
throw new Error(`Using "null" as filter value is not supported yet`);
}

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

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

// map field type to constraint type
const fieldType = match<string, 'number' | 'string' | 'boolean'>(fieldInfo.type)
.with(P.union('Int', 'BigInt', 'Float', 'Decimal'), () => 'number')
.with('String', () => 'string')
.with('Boolean', () => 'boolean')
.otherwise(() => {
throw new Error(
`Providing filter for field "${field}" is not supported. Only number, string, and boolean fields are allowed.`
);
});

// check value type
const valueType = typeof value;
if (valueType !== 'number' && valueType !== 'string' && valueType !== 'boolean') {
throw new Error(
`Invalid value type for field "${field}". Only number, string or boolean is allowed.`
);
}

if (fieldType !== valueType) {
throw new Error(`Invalid value type for field "${field}". Expected "${fieldType}".`);
}

// check number validity
if (typeof value === 'number' && (!Number.isInteger(value) || value < 0)) {
throw new Error(`Invalid value for field "${field}". Only non-negative integers are allowed.`);
}

// build a constraint
extraConstraints.push({
kind: 'eq',
left: { kind: 'variable', name: field, type: fieldType },
right: { kind: 'value', value, type: fieldType },
});
}

if (extraConstraints.length > 0) {
// combine the constraints
constraint = { kind: 'and', children: [constraint, ...extraConstraints] };
}
}

// check satisfiability
return new ConstraintSolver().checkSat(constraint);
}

//#endregion

//#region Utils

private get shouldLogQuery() {
Expand Down
Loading