Skip to content

Commit 4ef27c7

Browse files
authored
feat(policy): support comparing auth() with auth model (#244)
* feat(policy): support comparing `auth()` with auth model * fix file name
1 parent 67d35d5 commit 4ef27c7

File tree

10 files changed

+246
-61
lines changed

10 files changed

+246
-61
lines changed

packages/language/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,14 @@
5959
},
6060
"devDependencies": {
6161
"@types/pluralize": "^0.0.33",
62+
"@types/tmp": "catalog:",
63+
"@zenstackhq/common-helpers": "workspace:*",
6264
"@zenstackhq/eslint-config": "workspace:*",
6365
"@zenstackhq/typescript-config": "workspace:*",
64-
"@zenstackhq/common-helpers": "workspace:*",
6566
"@zenstackhq/vitest-config": "workspace:*",
67+
"glob": "^11.0.2",
6668
"langium-cli": "catalog:",
67-
"tmp": "catalog:",
68-
"@types/tmp": "catalog:"
69+
"tmp": "catalog:"
6970
},
7071
"volta": {
7172
"node": "18.19.1",

packages/language/src/validators/expression-validator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,12 @@ export default class ExpressionValidator implements AstValidator<Expression> {
207207
isDataFieldReference(expr.left) &&
208208
(isThisExpr(expr.right) || isDataFieldReference(expr.right))
209209
) {
210-
accept('error', 'comparison between model-typed fields are not supported', { node: expr });
210+
accept('error', 'comparison between models is not supported', { node: expr });
211211
} else if (
212212
isDataFieldReference(expr.right) &&
213213
(isThisExpr(expr.left) || isDataFieldReference(expr.left))
214214
) {
215-
accept('error', 'comparison between model-typed fields are not supported', { node: expr });
215+
accept('error', 'comparison between models is not supported', { node: expr });
216216
}
217217
} else if (
218218
(isDataModel(leftType) && !isNullExpr(expr.right)) ||
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it } from 'vitest';
2+
import { loadSchema, loadSchemaWithError } from './utils';
3+
4+
describe('Expression Validation Tests', () => {
5+
it('should reject model comparison', async () => {
6+
await loadSchemaWithError(
7+
`
8+
model User {
9+
id Int @id
10+
name String
11+
posts Post[]
12+
}
13+
14+
model Post {
15+
id Int @id
16+
title String
17+
author User @relation(fields: [authorId], references: [id])
18+
@@allow('all', author == this)
19+
}
20+
`,
21+
'comparison between models is not supported',
22+
);
23+
});
24+
25+
it('should reject model comparison', async () => {
26+
await loadSchemaWithError(
27+
`
28+
model User {
29+
id Int @id
30+
name String
31+
profile Profile?
32+
address Address?
33+
@@allow('read', profile == this)
34+
}
35+
36+
model Profile {
37+
id Int @id
38+
bio String
39+
user User @relation(fields: [userId], references: [id])
40+
userId Int @unique
41+
}
42+
43+
model Address {
44+
id Int @id
45+
street String
46+
user User @relation(fields: [userId], references: [id])
47+
userId Int @unique
48+
}
49+
`,
50+
'comparison between models is not supported',
51+
);
52+
});
53+
54+
it('should allow auth comparison with auth type', async () => {
55+
await loadSchema(
56+
`
57+
datasource db {
58+
provider = 'sqlite'
59+
url = 'file:./dev.db'
60+
}
61+
62+
model User {
63+
id Int @id
64+
name String
65+
profile Profile?
66+
@@allow('read', auth() == this)
67+
}
68+
69+
model Profile {
70+
id Int @id
71+
bio String
72+
user User @relation(fields: [userId], references: [id])
73+
userId Int @unique
74+
@@allow('read', auth() == user)
75+
}
76+
`,
77+
);
78+
});
79+
80+
it('should reject auth comparison with non-auth type', async () => {
81+
await loadSchemaWithError(
82+
`
83+
model User {
84+
id Int @id
85+
name String
86+
profile Profile?
87+
}
88+
89+
model Profile {
90+
id Int @id
91+
bio String
92+
user User @relation(fields: [userId], references: [id])
93+
userId Int @unique
94+
@@allow('read', auth() == this)
95+
}
96+
`,
97+
'incompatible operand types',
98+
);
99+
});
100+
});

packages/language/test/utils.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1+
import { invariant } from '@zenstackhq/common-helpers';
2+
import { glob } from 'glob';
3+
import fs from 'node:fs';
14
import os from 'node:os';
25
import path from 'node:path';
3-
import fs from 'node:fs';
4-
import { loadDocument } from '../src';
56
import { expect } from 'vitest';
6-
import { invariant } from '@zenstackhq/common-helpers';
7+
import { loadDocument } from '../src';
78

89
export async function loadSchema(schema: string) {
910
// create a temp file
1011
const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`);
1112
fs.writeFileSync(tempFile, schema);
12-
const r = await loadDocument(tempFile);
13-
expect(r.success).toBe(true);
13+
const r = await loadDocument(tempFile, getPluginModels());
14+
expect(r).toSatisfy(
15+
(r) => r.success,
16+
`Failed to load schema: ${(r as any).errors?.map((e) => e.toString()).join(', ')}`,
17+
);
1418
invariant(r.success);
1519
return r.model;
1620
}
@@ -19,12 +23,21 @@ export async function loadSchemaWithError(schema: string, error: string | RegExp
1923
// create a temp file
2024
const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`);
2125
fs.writeFileSync(tempFile, schema);
22-
const r = await loadDocument(tempFile);
26+
const r = await loadDocument(tempFile, getPluginModels());
2327
expect(r.success).toBe(false);
2428
invariant(!r.success);
2529
if (typeof error === 'string') {
26-
expect(r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase()))).toBe(true);
30+
expect(r).toSatisfy(
31+
(r) => r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase())),
32+
`Expected error message to include "${error}" but got: ${r.errors.map((e) => e.toString()).join(', ')}`,
33+
);
2734
} else {
28-
expect(r.errors.some((e) => error.test(e))).toBe(true);
35+
expect(r).toSatisfy(
36+
(r) => r.errors.some((e) => error.test(e)),
37+
`Expected error message to match "${error}" but got: ${r.errors.map((e) => e.toString()).join(', ')}`,
38+
);
2939
}
3040
}
41+
function getPluginModels() {
42+
return glob.sync(path.resolve(__dirname, '../../runtime/src/plugins/**/plugin.zmodel'));
43+
}

packages/runtime/src/client/crud/operations/base.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,10 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
567567
select: fieldsToSelectObject(referencedPkFields) as any,
568568
});
569569
if (!relationEntity) {
570-
throw new NotFoundError(`Could not find the entity for connect action`);
570+
throw new NotFoundError(
571+
relationModel,
572+
`Could not find the entity to connect for the relation "${relationField.name}"`,
573+
);
571574
}
572575
result = relationEntity;
573576
}

packages/runtime/src/client/errors.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class InternalError extends Error {}
2525
* Error thrown when an entity is not found.
2626
*/
2727
export class NotFoundError extends Error {
28-
constructor(model: string) {
29-
super(`Entity not found for model "${model}"`);
28+
constructor(model: string, details?: string) {
29+
super(`Entity not found for model "${model}"${details ? `: ${details}` : ''}`);
3030
}
3131
}

packages/runtime/src/plugins/policy/expression-transformer.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,7 @@ export class ExpressionTransformer<Schema extends SchemaDef> {
305305
private _unary(expr: UnaryExpression, context: ExpressionTransformerContext<Schema>) {
306306
// only '!' operator for now
307307
invariant(expr.op === '!', 'only "!" operator is supported');
308-
return BinaryOperationNode.create(
309-
this.transform(expr.operand, context),
310-
this.transformOperator('!='),
311-
trueNode(this.dialect),
312-
);
308+
return logicalNot(this.transform(expr.operand, context));
313309
}
314310

315311
private transformOperator(op: Exclude<BinaryOperator, '?' | '!' | '^'>) {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createPolicyTestClient } from './utils';
3+
4+
describe('Reference Equality Tests', () => {
5+
it('works with create and auth equality', async () => {
6+
const db = await createPolicyTestClient(
7+
`
8+
model User {
9+
id1 Int
10+
id2 Int
11+
posts Post[]
12+
@@id([id1, id2])
13+
@@allow('all', auth() == this)
14+
@@allow('read', true)
15+
}
16+
17+
model Post {
18+
id Int @id @default(autoincrement())
19+
title String
20+
authorId1 Int
21+
authorId2 Int
22+
author User @relation(fields: [authorId1, authorId2], references: [id1, id2])
23+
@@allow('all', auth() == author)
24+
}
25+
`,
26+
);
27+
28+
await expect(
29+
db.user.create({
30+
data: { id1: 1, id2: 2 },
31+
}),
32+
).toBeRejectedByPolicy();
33+
34+
await expect(
35+
db.$setAuth({ id1: 1, id2: 2 }).user.create({
36+
data: { id1: 1, id2: 2 },
37+
}),
38+
).resolves.toMatchObject({ id1: 1, id2: 2 });
39+
40+
await expect(
41+
db.post.create({
42+
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
43+
}),
44+
).toBeRejectedByPolicy();
45+
await expect(
46+
db.post.create({
47+
data: { author: { connect: { id1_id2: { id1: 1, id2: 2 } } }, title: 'Post 1' },
48+
}),
49+
).toBeRejectedByPolicy();
50+
51+
await expect(
52+
db.$setAuth({ id1: 1, id2: 2 }).post.create({
53+
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
54+
}),
55+
).resolves.toMatchObject({ title: 'Post 1' });
56+
await expect(
57+
db.$setAuth({ id1: 1, id2: 2 }).post.create({
58+
data: { author: { connect: { id1_id2: { id1: 1, id2: 2 } } }, title: 'Post 2' },
59+
}),
60+
).resolves.toMatchObject({ title: 'Post 2' });
61+
});
62+
63+
it('works with create and auth inequality', async () => {
64+
const db = await createPolicyTestClient(
65+
`
66+
model User {
67+
id1 Int
68+
id2 Int
69+
posts Post[]
70+
@@id([id1, id2])
71+
@@allow('all', auth() != this)
72+
@@allow('read', true)
73+
}
74+
75+
model Post {
76+
id Int @id @default(autoincrement())
77+
title String
78+
authorId1 Int
79+
authorId2 Int
80+
author User @relation(fields: [authorId1, authorId2], references: [id1, id2])
81+
@@allow('all', auth() != author)
82+
@@allow('read', true)
83+
}
84+
`,
85+
);
86+
87+
await expect(
88+
db.$setAuth({ id1: 1, id2: 2 }).user.create({
89+
data: { id1: 1, id2: 2 },
90+
}),
91+
).toBeRejectedByPolicy();
92+
await expect(
93+
db.$setAuth({ id1: 2, id2: 2 }).user.create({
94+
data: { id1: 1, id2: 2 },
95+
}),
96+
).toResolveTruthy();
97+
98+
await expect(
99+
db.$setAuth({ id1: 1, id2: 2 }).post.create({
100+
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
101+
}),
102+
).toBeRejectedByPolicy();
103+
await expect(
104+
db.$setAuth({ id1: 2, id2: 2 }).post.create({
105+
data: { authorId1: 1, authorId2: 2, title: 'Post 1' },
106+
}),
107+
).resolves.toMatchObject({ title: 'Post 1' });
108+
});
109+
});

packages/runtime/test/policy/ref-equality.test.ts

Lines changed: 0 additions & 40 deletions
This file was deleted.

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)