Skip to content

Commit 7c0277d

Browse files
committed
fix: handle nullable auth() access
1 parent ae96914 commit 7c0277d

File tree

3 files changed

+123
-21
lines changed

3 files changed

+123
-21
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ export class ConstraintSolver {
8383
}
8484

8585
private buildComparisonFormula(constraint: ComparisonConstraint) {
86+
if (constraint.left.kind === 'value' && constraint.right.kind === 'value') {
87+
// constant comparison
88+
const left: ValueConstraint = constraint.left;
89+
const right: ValueConstraint = constraint.right;
90+
return match(constraint.kind)
91+
.with('eq', () => (left.value === right.value ? Logic.TRUE : Logic.FALSE))
92+
.with('gt', () => (left.value > right.value ? Logic.TRUE : Logic.FALSE))
93+
.with('gte', () => (left.value >= right.value ? Logic.TRUE : Logic.FALSE))
94+
.with('lt', () => (left.value < right.value ? Logic.TRUE : Logic.FALSE))
95+
.with('lte', () => (left.value <= right.value ? Logic.TRUE : Logic.FALSE))
96+
.exhaustive();
97+
}
98+
8699
return match(constraint.kind)
87100
.with('eq', () => this.transformEquality(constraint.left, constraint.right))
88101
.with('gt', () =>

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

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
isDataModelField,
1515
isLiteralExpr,
1616
isMemberAccessExpr,
17+
isNullExpr,
1718
isReferenceExpr,
1819
isThisExpr,
1920
isUnaryExpr,
@@ -125,13 +126,19 @@ export class ConstraintTransformer {
125126
}
126127

127128
private transformMemberAccess(expr: MemberAccessExpr) {
129+
// "this.x" is transformed into a named variable
128130
if (isThisExpr(expr.operand)) {
129-
// "this.x" is transformed into a named variable
130131
return this.variable(expr.member.$refText, 'boolean');
131132
}
132133

133-
// other member access expressions are not supported and thus
134-
// transformed into a free variable
134+
// top-level auth access
135+
const authAccess = this.getAuthAccess(expr);
136+
if (authAccess) {
137+
return this.value(`${authAccess} ?? false`, 'boolean');
138+
}
139+
140+
// other top-level member access expressions are not supported
141+
// and thus transformed into a free variable
135142
return this.nextVar();
136143
}
137144

@@ -153,14 +160,19 @@ export class ConstraintTransformer {
153160
}
154161

155162
private transformComparison(expr: BinaryExpr) {
156-
const leftOperand = this.getComparisonOperand(expr.left);
157-
const rightOperand = this.getComparisonOperand(expr.right);
163+
if (this.isAuthEqualNull(expr)) {
164+
// `auth() == null` => `user === null`
165+
return this.value(`${this.options.authAccessor} === null`, 'boolean');
166+
}
158167

159-
if (leftOperand === undefined || rightOperand === undefined) {
160-
// if either operand is not supported, transform into a free variable
161-
return this.nextVar();
168+
if (this.isAuthNotEqualNull(expr)) {
169+
// `auth() != null` => `user !== null`
170+
return this.value(`${this.options.authAccessor} !== null`, 'boolean');
162171
}
163172

173+
const leftOperand = this.getComparisonOperand(expr.left);
174+
const rightOperand = this.getComparisonOperand(expr.right);
175+
164176
const op = match(expr.operator)
165177
.with('==', () => 'eq')
166178
.with('!=', () => 'eq')
@@ -175,12 +187,52 @@ export class ConstraintTransformer {
175187
let result = `{ kind: '${op}', left: ${leftOperand}, right: ${rightOperand} }`;
176188
if (expr.operator === '!=') {
177189
// transform "!=" into "not eq"
178-
result = `{ kind: 'not', children: [${result}] }`;
190+
result = this.not(result);
191+
}
192+
193+
// `auth()` access can be undefined, when that happens, we assume a false condition
194+
// for the comparison, unless we're directly comparing `auth() != null`
195+
196+
const leftAuthAccess = this.getAuthAccess(expr.left);
197+
const rightAuthAccess = this.getAuthAccess(expr.right);
198+
199+
if (leftAuthAccess) {
200+
// `auth().f op x` => `auth().f !== undefined && auth().f op x`
201+
return this.and(this.value(`${this.normalizeToNull(leftAuthAccess)} !== null`, 'boolean'), result);
202+
} else if (rightAuthAccess) {
203+
// `x op auth().f` => `auth().f !== undefined && x op auth().f`
204+
return this.and(this.value(`${this.normalizeToNull(rightAuthAccess)} !== null`, 'boolean'), result);
205+
}
206+
207+
if (leftOperand === undefined || rightOperand === undefined) {
208+
// if either operand is not supported, transform into a free variable
209+
return this.nextVar();
179210
}
180211

181212
return result;
182213
}
183214

215+
// normalize `auth()` access undefined value to null
216+
private normalizeToNull(expr: string) {
217+
return `(${expr} ?? null)`;
218+
}
219+
220+
private isAuthEqualNull(expr: BinaryExpr) {
221+
return (
222+
expr.operator === '==' &&
223+
((isAuthInvocation(expr.left) && isNullExpr(expr.right)) ||
224+
(isAuthInvocation(expr.right) && isNullExpr(expr.left)))
225+
);
226+
}
227+
228+
private isAuthNotEqualNull(expr: BinaryExpr) {
229+
return (
230+
expr.operator === '!=' &&
231+
((isAuthInvocation(expr.left) && isNullExpr(expr.right)) ||
232+
(isAuthInvocation(expr.right) && isNullExpr(expr.left)))
233+
);
234+
}
235+
184236
private getComparisonOperand(expr: Expression) {
185237
if (isLiteralExpr(expr)) {
186238
return this.transformLiteral(expr);
@@ -199,16 +251,9 @@ export class ConstraintTransformer {
199251

200252
const authAccess = this.getAuthAccess(expr);
201253
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
205-
const fieldAccess = `${this.options.authAccessor}?.${authAccess}`;
206254
const mappedType = this.mapType(expr);
207255
if (mappedType) {
208-
return `${fieldAccess} === undefined ? ${this.expressionVariable(expr, mappedType)} : ${this.value(
209-
fieldAccess,
210-
mappedType
211-
)}`;
256+
return `${this.value(authAccess, mappedType)}`;
212257
} else {
213258
return undefined;
214259
}
@@ -241,7 +286,7 @@ export class ConstraintTransformer {
241286
}
242287

243288
if (isAuthInvocation(expr.operand)) {
244-
return expr.member.$refText;
289+
return `${this.options.authAccessor}?.${expr.member.$refText}`;
245290
} else {
246291
const operand = this.getAuthAccess(expr.operand);
247292
return operand ? `${operand}?.${expr.member.$refText}` : undefined;

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

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,19 +277,63 @@ describe('Permission checker', () => {
277277
model User {
278278
id Int @id @default(autoincrement())
279279
level Int
280+
admin Boolean
280281
}
281282
282283
model Model {
283284
id Int @id @default(autoincrement())
284285
value Int
285286
@@allow('read', auth().level > 0)
287+
@@allow('create', auth().admin)
288+
@@allow('update', !auth().admin)
286289
}
287290
`
288291
);
289292

290-
await expect(enhance().model.check('read')).toResolveTruthy();
293+
await expect(enhance().model.check('read')).toResolveFalsy();
294+
await expect(enhance({ id: 1 }).model.check('read')).toResolveFalsy();
291295
await expect(enhance({ id: 1, level: 0 }).model.check('read')).toResolveFalsy();
292296
await expect(enhance({ id: 1, level: 1 }).model.check('read')).toResolveTruthy();
297+
298+
await expect(enhance().model.check('create')).toResolveFalsy();
299+
await expect(enhance({ id: 1 }).model.check('create')).toResolveFalsy();
300+
await expect(enhance({ id: 1, admin: false }).model.check('create')).toResolveFalsy();
301+
await expect(enhance({ id: 1, admin: true }).model.check('create')).toResolveTruthy();
302+
303+
await expect(enhance().model.check('update')).toResolveTruthy();
304+
await expect(enhance({ id: 1 }).model.check('update')).toResolveTruthy();
305+
await expect(enhance({ id: 1, admin: true }).model.check('update')).toResolveFalsy();
306+
await expect(enhance({ id: 1, admin: false }).model.check('update')).toResolveTruthy();
307+
});
308+
309+
it('auth null check', async () => {
310+
const { enhance } = await loadSchema(
311+
`
312+
model User {
313+
id Int @id @default(autoincrement())
314+
level Int
315+
}
316+
317+
model Model {
318+
id Int @id @default(autoincrement())
319+
value Int
320+
@@allow('read', auth() != null)
321+
@@allow('create', auth() == null)
322+
@@allow('update', auth().level > 0)
323+
}
324+
`
325+
);
326+
327+
await expect(enhance().model.check('read')).toResolveFalsy();
328+
await expect(enhance({ id: 1 }).model.check('read')).toResolveTruthy();
329+
330+
await expect(enhance().model.check('create')).toResolveTruthy();
331+
await expect(enhance({ id: 1 }).model.check('create')).toResolveFalsy();
332+
333+
await expect(enhance().model.check('update')).toResolveFalsy();
334+
await expect(enhance({ id: 1 }).model.check('update')).toResolveFalsy();
335+
await expect(enhance({ id: 1, level: 0 }).model.check('update')).toResolveFalsy();
336+
await expect(enhance({ id: 1, level: 1 }).model.check('update')).toResolveTruthy();
293337
});
294338

295339
it('auth with relation', async () => {
@@ -315,8 +359,8 @@ describe('Permission checker', () => {
315359
`
316360
);
317361

318-
await expect(enhance().model.check('read')).toResolveTruthy();
319-
await expect(enhance({ id: 1 }).model.check('read')).toResolveTruthy();
362+
await expect(enhance().model.check('read')).toResolveFalsy();
363+
await expect(enhance({ id: 1 }).model.check('read')).toResolveFalsy();
320364
await expect(enhance({ id: 1, profile: { level: 0 } }).model.check('read')).toResolveFalsy();
321365
await expect(enhance({ id: 1, profile: { level: 1 } }).model.check('read')).toResolveTruthy();
322366
});

0 commit comments

Comments
 (0)