Skip to content

feat: implementing permission checker #1411

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

Merged
merged 16 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 13 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
219 changes: 219 additions & 0 deletions packages/runtime/src/enhancements/policy/constraint-solver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
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', 'ne', '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', () => this.buildAndFormula(constraint))
.with('or', () => this.buildOrFormula(constraint))
.with('not', () => this.buildNotFormula(constraint))
.exhaustive();
}

private buildAndFormula(constraint: LogicalConstraint): Logic.Formula {
if (constraint.children.some((c) => this.isFalse(c))) {
// short-circuit
return Logic.FALSE;
}
return Logic.and(...constraint.children.map((c) => this.buildFormula(c)));
}

private buildOrFormula(constraint: LogicalConstraint): Logic.Formula {
if (constraint.children.some((c) => this.isTrue(c))) {
// short-circuit
return Logic.TRUE;
}
return Logic.or(...constraint.children.map((c) => this.buildFormula(c)));
}

private buildNotFormula(constraint: LogicalConstraint) {
if (constraint.children.length !== 1) {
throw new Error('"not" constraint must have exactly one child');
}
return Logic.not(this.buildFormula(constraint.children[0]));
}

private isTrue(constraint: CheckerConstraint): unknown {
return constraint.kind === 'value' && constraint.value === true;
}

private isFalse(constraint: CheckerConstraint): unknown {
return constraint.kind === 'value' && constraint.value === false;
}

private buildComparisonFormula(constraint: ComparisonConstraint) {
if (constraint.left.kind === 'value' && constraint.right.kind === 'value') {
// constant comparison
const left: ValueConstraint = constraint.left;
const right: ValueConstraint = constraint.right;
return match(constraint.kind)
.with('eq', () => (left.value === right.value ? Logic.TRUE : Logic.FALSE))
.with('ne', () => (left.value !== right.value ? Logic.TRUE : Logic.FALSE))
.with('gt', () => (left.value > right.value ? Logic.TRUE : Logic.FALSE))
.with('gte', () => (left.value >= right.value ? Logic.TRUE : Logic.FALSE))
.with('lt', () => (left.value < right.value ? Logic.TRUE : Logic.FALSE))
.with('lte', () => (left.value <= right.value ? Logic.TRUE : Logic.FALSE))
.exhaustive();
}

return match(constraint.kind)
.with('eq', () => this.transformEquality(constraint.left, constraint.right))
.with('ne', () => this.transformInequality(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 transformInequality(left: ComparisonTerm, right: ComparisonTerm) {
return Logic.not(this.transformEquality(left, right));
}

private transformComparison(
left: ComparisonTerm,
right: ComparisonTerm,
func: (left: Logic.Formula, right: Logic.Formula) => Logic.Formula
) {
return func(this.buildFormula(left), this.buildFormula(right));
}
}
95 changes: 94 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,96 @@ 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> {
return createDeferredPromise(() => this.doCheck(operation, fieldValues));
}

private async doCheck(operation: PolicyCrudKind, fieldValues?: Record<string, number | string | boolean | null>) {
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
Loading