Skip to content

Commit b55740a

Browse files
committed
more fixes and tests
1 parent 2ef2da0 commit b55740a

File tree

10 files changed

+174
-10
lines changed

10 files changed

+174
-10
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type {
1010
} from '../types';
1111

1212
/**
13-
* A boolean constraint solver based on `logic-solver`.
13+
* A boolean constraint solver based on `logic-solver`. Only boolean and integer types are supported.
1414
*/
1515
export class ConstraintSolver {
1616
// a table for internalizing string literals

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,11 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
14411441

14421442
//#region Check
14431443

1444+
/**
1445+
* Checks if the given operation is possibly allowed by the policy, without querying the database.
1446+
* @param operation The CRUD operation.
1447+
* @param fieldValues Extra field value filters to be combined with the policy constraints.
1448+
*/
14441449
async check(
14451450
operation: PolicyCrudKind,
14461451
fieldValues?: Record<string, number | string | boolean | null>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,109 @@
1+
/**
2+
* Type definitions for the `logic-solver` npm package.
3+
*/
14
declare module 'logic-solver' {
5+
/**
6+
* A boolean formula.
7+
*/
28
interface Formula {}
39

10+
/**
11+
* The `TRUE` formula.
12+
*/
413
const TRUE: Formula;
514

15+
/**
16+
* The `FALSE` formula.
17+
*/
618
const FALSE: Formula;
719

20+
/**
21+
* Boolean equivalence.
22+
*/
823
export function equiv(operand1: Formula, operand2: Formula): Formula;
924

25+
/**
26+
* Bits equality.
27+
*/
1028
export function equalBits(bits1: Formula, bits2: Formula): Formula;
1129

30+
/**
31+
* Bits greater-than.
32+
*/
1233
export function greaterThan(bits1: Formula, bits2: Formula): Formula;
1334

35+
/**
36+
* Bits greater-than-or-equal.
37+
*/
1438
export function greaterThanOrEqual(bits1: Formula, bits2: Formula): Formula;
1539

40+
/**
41+
* Bits less-than.
42+
*/
1643
export function lessThan(bits1: Formula, bits2: Formula): Formula;
1744

45+
/**
46+
* Bits less-than-or-equal.
47+
*/
1848
export function lessThanOrEqual(bits1: Formula, bits2: Formula): Formula;
1949

50+
/**
51+
* Logical AND.
52+
*/
2053
export function and(...args: Formula[]): Formula;
2154

55+
/**
56+
* Logical OR.
57+
*/
2258
export function or(...args: Formula[]): Formula;
2359

60+
/**
61+
* Logical NOT.
62+
*/
2463
export function not(arg: Formula): Formula;
2564

65+
/**
66+
* Creates a bits variable with the given name and bit length.
67+
*/
2668
export function variableBits(baseName: string, N: number): Formula;
2769

70+
/**
71+
* Creates a constant bits formula from the given whole number.
72+
*/
2873
export function constantBits(wholeNumber: number): Formula;
2974

75+
/**
76+
* A solution to a constraint.
77+
*/
3078
interface Solution {
79+
/**
80+
* Returns a map of variable assignments.
81+
*/
3182
getMap(): object;
3283

84+
/**
85+
* Evaluates the given formula against the solution.
86+
*/
3387
evaluate(formula: Formula): unknown;
3488
}
3589

90+
/**
91+
* A constraint solver.
92+
*/
3693
class Solver {
94+
/**
95+
* Adds constraints to the solver.
96+
*/
3797
require(...args: Formula[]): void;
3898

99+
/**
100+
* Adds negated constraints from the solver.
101+
*/
39102
forbid(...args: Formula[]): void;
40103

104+
/**
105+
* Solves the constraints.
106+
*/
41107
solve(): Solution;
42108
}
43109
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,9 @@ export class PolicyUtil extends QueryUtils {
565565

566566
//#region Checker
567567

568+
/**
569+
* Gets checker constraints for the given model and operation.
570+
*/
568571
getCheckerConstraint(model: string, operation: PolicyCrudKind): ReturnType<CheckerFunc> | boolean {
569572
const checker = this.getModelChecker(model);
570573
if (!checker) {
@@ -576,9 +579,11 @@ export class PolicyUtil extends QueryUtils {
576579
return provider;
577580
}
578581

579-
if (!provider) {
580-
throw this.unknownError(`unable to load authorization guard for ${model}`);
582+
if (typeof provider !== 'function') {
583+
throw this.unknownError(`unable to ${operation} checker for ${model}`);
581584
}
585+
586+
// call checker function
582587
return provider({ user: this.user });
583588
}
584589

packages/runtime/src/enhancements/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,31 +33,55 @@ export interface CommonEnhancementOptions {
3333
*/
3434
export type PolicyFunc = (context: QueryContext, db: CrudContract) => object;
3535

36+
/**
37+
* Function for checking if an operation is possibly allowed.
38+
*/
3639
export type CheckerFunc = (context: CheckerContext) => CheckerConstraint;
3740

41+
/**
42+
* Supported checker constraint checking value types.
43+
*/
3844
export type ConstraintValueTypes = 'boolean' | 'number' | 'string';
3945

46+
/**
47+
* Free variable constraint
48+
*/
4049
export type VariableConstraint = { kind: 'variable'; name: string; type: ConstraintValueTypes };
4150

51+
/**
52+
* Constant value constraint
53+
*/
4254
export type ValueConstraint = {
4355
kind: 'value';
4456
value: number | boolean | string;
4557
type: ConstraintValueTypes;
4658
};
4759

60+
/**
61+
* Terms for comparison constraints
62+
*/
4863
export type ComparisonTerm = VariableConstraint | ValueConstraint;
4964

65+
/**
66+
* Comparison constraint
67+
*/
5068
export type ComparisonConstraint = {
5169
kind: 'eq' | 'gt' | 'gte' | 'lt' | 'lte';
5270
left: ComparisonTerm;
5371
right: ComparisonTerm;
5472
};
5573

74+
/**
75+
* Logical constraint
76+
*/
5677
export type LogicalConstraint = {
5778
kind: 'and' | 'or' | 'not';
5879
children: CheckerConstraint[];
5980
};
6081

82+
/**
83+
* Operation allowability checking constraint
84+
*/
6185
export type CheckerConstraint = ValueConstraint | VariableConstraint | ComparisonConstraint | LogicalConstraint;
6286

6387
/**

packages/runtime/src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,19 @@ export type QueryContext = {
5858
preValue?: any;
5959
};
6060

61+
/**
62+
* Context for checking operation allowability.
63+
*/
6164
export type CheckerContext = {
65+
/**
66+
* Current user
67+
*/
6268
user?: AuthUser;
6369

64-
fieldValues?: Record<string, string | number | boolean | null>;
70+
/**
71+
* Extra field value filters.
72+
*/
73+
fieldValues?: Record<string, string | number | boolean>;
6574
};
6675

6776
/**

packages/schema/src/plugins/enhancer/enhance/checker-type-generator.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import { P, match } from 'ts-pattern';
55

66
/**
77
* Generates a `ModelCheckers` interface that contains a `check` method for each model in the schema.
8+
*
9+
* E.g.:
10+
*
11+
* ```ts
12+
* type CheckerOperation = 'create' | 'read' | 'update' | 'delete';
13+
*
14+
* export interface ModelCheckers {
15+
* user: { check(op: CheckerOperation, args?: { email?: string; age?: number; }): Promise<boolean> },
16+
* ...
17+
* }
18+
* ```
819
*/
920
export function generateCheckerType(model: Model) {
1021
return `

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,52 @@ import {
2020
} from '@zenstackhq/sdk/ast';
2121
import { P, match } from 'ts-pattern';
2222

23+
/**
24+
* Options for {@link ConstraintTransformer}.
25+
*/
2326
export type ConstraintTransformerOptions = {
2427
authAccessor: string;
2528
};
2629

30+
/**
31+
* Transform a set of allow and deny rules into a single constraint expression.
32+
*/
2733
export class ConstraintTransformer {
34+
// a counter for generating unique variable names
2835
private varCounter = 0;
2936

3037
constructor(private readonly options: ConstraintTransformerOptions) {}
3138

39+
/**
40+
* Transforms a set of allow and deny rules into a single constraint expression.
41+
*/
3242
transformRules(allows: Expression[], denies: Expression[]): string {
43+
// reset state
3344
this.varCounter = 0;
3445

3546
if (allows.length === 0) {
47+
// unconditionally deny
3648
return this.value('false', 'boolean');
3749
}
3850

3951
let result: string;
4052

53+
// transform allow rules
4154
const allowConstraints = allows.map((allow) => this.transformExpression(allow));
4255
if (allowConstraints.length > 1) {
4356
result = this.and(...allowConstraints);
4457
} else {
4558
result = allowConstraints[0];
4659
}
4760

61+
// transform deny rules and compose
4862
if (denies.length > 0) {
4963
const denyConstraints = denies.map((deny) => this.transformExpression(deny));
5064
result = this.and(result, this.not(this.or(...denyConstraints)));
5165
}
5266

53-
console.log(`Constraint transformation result:\n${JSON.stringify(result, null, 2)}`);
67+
// DEBUG:
68+
// console.log(`Constraint transformation result:\n${JSON.stringify(result, null, 2)}`);
5469

5570
return result;
5671
}
@@ -105,22 +120,30 @@ export class ConstraintTransformer {
105120
}
106121

107122
private transformReference(expr: ReferenceExpr) {
123+
// top-level reference is transformed into a named variable
108124
return this.variable(expr.target.$refText, 'boolean');
109125
}
110126

111127
private transformMemberAccess(expr: MemberAccessExpr) {
112128
if (isThisExpr(expr.operand)) {
129+
// "this.x" is transformed into a named variable
113130
return this.variable(expr.member.$refText, 'boolean');
114131
}
132+
133+
// other member access expressions are not supported and thus
134+
// transformed into a free variable
115135
return this.nextVar();
116136
}
117137

118138
private transformBinary(expr: BinaryExpr): string {
119-
return match(expr.operator)
120-
.with('&&', () => this.and(this.transformExpression(expr.left), this.transformExpression(expr.right)))
121-
.with('||', () => this.or(this.transformExpression(expr.left), this.transformExpression(expr.right)))
122-
.with(P.union('==', '!=', '<', '<=', '>', '>='), () => this.transformComparison(expr))
123-
.otherwise(() => this.nextVar());
139+
return (
140+
match(expr.operator)
141+
.with('&&', () => this.and(this.transformExpression(expr.left), this.transformExpression(expr.right)))
142+
.with('||', () => this.or(this.transformExpression(expr.left), this.transformExpression(expr.right)))
143+
.with(P.union('==', '!=', '<', '<=', '>', '>='), () => this.transformComparison(expr))
144+
// unsupported operators (e.g., collection predicate) are transformed into a free variable
145+
.otherwise(() => this.nextVar())
146+
);
124147
}
125148

126149
private transformUnary(expr: UnaryExpr): string {
@@ -134,6 +157,7 @@ export class ConstraintTransformer {
134157
const rightOperand = this.getComparisonOperand(expr.right);
135158

136159
if (leftOperand === undefined || rightOperand === undefined) {
160+
// if either operand is not supported, transform into a free variable
137161
return this.nextVar();
138162
}
139163

@@ -150,6 +174,7 @@ export class ConstraintTransformer {
150174

151175
let result = `{ kind: '${op}', left: ${leftOperand}, right: ${rightOperand} }`;
152176
if (expr.operator === '!=') {
177+
// transform "!=" into "not eq"
153178
result = `{ kind: 'not', children: [${result}] }`;
154179
}
155180

@@ -163,6 +188,7 @@ export class ConstraintTransformer {
163188

164189
const fieldAccess = this.getFieldAccess(expr);
165190
if (fieldAccess) {
191+
// model field access is transformed into a named variable
166192
const mappedType = this.mapType(expr);
167193
if (mappedType) {
168194
return this.variable(fieldAccess.name, mappedType);
@@ -173,6 +199,9 @@ export class ConstraintTransformer {
173199

174200
const authAccess = this.getAuthAccess(expr);
175201
if (authAccess) {
202+
// `auth().` access is transformed into a runtime boolean value if it
203+
// doesn't evaluate to undefined (due to ?. chaining), otherwise into
204+
// a named variable
176205
const fieldAccess = `${this.options.authAccessor}?.${authAccess}`;
177206
const mappedType = this.mapType(expr);
178207
if (mappedType) {

packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,13 @@ export class PolicyGenerator {
8888

8989
const models = getDataModels(model);
9090

91+
// policy guard functions
9192
const policyMap: Record<string, Record<string, string | boolean | object>> = {};
9293
for (const model of models) {
9394
policyMap[model.name] = await this.generateQueryGuardForModel(model, sf);
9495
}
9596

97+
// CRUD checker functions
9698
const checkerMap: Record<string, Record<string, string | boolean>> = {};
9799
for (const model of models) {
98100
checkerMap[model.name] = await this.generateCheckerForModel(model, sf);

0 commit comments

Comments
 (0)