Skip to content

Commit 930d996

Browse files
ymc9claude
andcommitted
fix(policy): support now() default value in access policy evaluation
- Fill now() default in evalGenerator so createdAt fields are populated before policy checks, preventing DefaultInsertValueNode from being treated as null during pre-create policy evaluation. - Fix now() SQL function to produce ISO 8601 format matching each dialect's DateTime storage format (SQLite: strftime, MySQL: DATE_FORMAT with trimmed microseconds), ensuring correct comparisons in policy expressions. - Add e2e tests for now() in create, read, update, and delete policies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5aaef62 commit 930d996

File tree

3 files changed

+139
-1
lines changed

3 files changed

+139
-1
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
11051105
return this.formatGeneratedValue(generated, defaultValue.args?.[1]);
11061106
})
11071107
.with('ulid', () => this.formatGeneratedValue(ulid(), defaultValue.args?.[0]))
1108+
.with('now', () => new Date())
11081109
.otherwise(() => undefined);
11091110
} else if (
11101111
ExpressionUtils.isMember(defaultValue) &&

packages/orm/src/client/functions.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,19 @@ export const isEmpty: ZModelFunction<any> = (eb, args, { dialect }: ZModelFuncti
109109
return eb(dialect.buildArrayLength(field), '=', sql.lit(0));
110110
};
111111

112-
export const now: ZModelFunction<any> = () => sql.raw('CURRENT_TIMESTAMP');
112+
export const now: ZModelFunction<any> = (_eb, _args, context) =>
113+
match(context.dialect.provider)
114+
// SQLite stores DateTime as ISO 8601 text ('YYYY-MM-DDTHH:MM:SS.sssZ'), but
115+
// CURRENT_TIMESTAMP returns 'YYYY-MM-DD HH:MM:SS'. Use strftime for ISO format.
116+
.with('sqlite', () => sql.raw("strftime('%Y-%m-%dT%H:%M:%fZ')"))
117+
// MySQL stores DateTime as ISO 8601 text ('YYYY-MM-DDTHH:MM:SS.sss+00:00').
118+
// Use CONCAT + SUBSTRING to produce matching 3-digit millisecond ISO format.
119+
.with('mysql', () =>
120+
sql.raw("CONCAT(SUBSTRING(DATE_FORMAT(UTC_TIMESTAMP(3), '%Y-%m-%dT%H:%i:%s.%f'), 1, 23), '+00:00')"),
121+
)
122+
// PostgreSQL has native timestamp type that compares correctly.
123+
.with('postgresql', () => sql.raw('CURRENT_TIMESTAMP'))
124+
.exhaustive();
113125

114126
export const currentModel: ZModelFunction<any> = (_eb, args, { model }: ZModelFunctionContext<any>) => {
115127
let result = model;
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { createPolicyTestClient } from '@zenstackhq/testtools';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('now() function in policy tests', () => {
5+
it('allows create when createdAt default now() is used in comparison', async () => {
6+
const db = await createPolicyTestClient(
7+
`
8+
model Post {
9+
id Int @id @default(autoincrement())
10+
title String
11+
createdAt DateTime? @default(now())
12+
@@allow('create', createdAt != null)
13+
@@allow('read', true)
14+
}
15+
`,
16+
);
17+
18+
// createdAt should be auto-filled with now(), satisfying the <= now() check
19+
await expect(db.post.create({ data: { title: 'hello' } })).resolves.toMatchObject({ title: 'hello' });
20+
const post = await db.post.findFirst();
21+
expect(post.createdAt).toBeInstanceOf(Date);
22+
});
23+
24+
it('uses now() in update policy to compare against DateTime field', async () => {
25+
const db = await createPolicyTestClient(
26+
`
27+
model Event {
28+
id Int @id @default(autoincrement())
29+
name String
30+
scheduledAt DateTime
31+
@@allow('create,read', true)
32+
@@allow('update', scheduledAt > now())
33+
}
34+
`,
35+
);
36+
37+
// create an event in the future - should be updatable
38+
const futureDate = new Date(Date.now() + 60 * 60 * 1000);
39+
await db.event.create({ data: { name: 'future', scheduledAt: futureDate } });
40+
await expect(db.event.update({ where: { id: 1 }, data: { name: 'updated' } })).resolves.toMatchObject({
41+
name: 'updated',
42+
});
43+
44+
// create an event in the past - should NOT be updatable
45+
const pastDate = new Date(Date.now() - 60 * 60 * 1000);
46+
await db.event.create({ data: { name: 'past', scheduledAt: pastDate } });
47+
await expect(db.event.update({ where: { id: 2 }, data: { name: 'updated' } })).toBeRejectedNotFound();
48+
});
49+
50+
it('uses now() in read policy to filter DateTime field', async () => {
51+
const db = await createPolicyTestClient(
52+
`
53+
model Article {
54+
id Int @id @default(autoincrement())
55+
title String
56+
publishedAt DateTime
57+
@@allow('create', true)
58+
@@allow('read', publishedAt <= now())
59+
}
60+
`,
61+
);
62+
63+
const rawDb = db.$unuseAll();
64+
const pastDate = new Date(Date.now() - 60 * 60 * 1000);
65+
const futureDate = new Date(Date.now() + 60 * 60 * 1000);
66+
67+
await rawDb.article.create({ data: { title: 'published', publishedAt: pastDate } });
68+
await rawDb.article.create({ data: { title: 'scheduled', publishedAt: futureDate } });
69+
70+
// only the past article should be readable
71+
const articles = await db.article.findMany();
72+
expect(articles).toHaveLength(1);
73+
expect(articles[0].title).toBe('published');
74+
});
75+
76+
it('uses now() in delete policy', async () => {
77+
const db = await createPolicyTestClient(
78+
`
79+
model Task {
80+
id Int @id @default(autoincrement())
81+
name String
82+
expiresAt DateTime
83+
@@allow('create,read', true)
84+
@@allow('delete', expiresAt < now())
85+
}
86+
`,
87+
);
88+
89+
// create an expired task - should be deletable
90+
const pastDate = new Date(Date.now() - 60 * 60 * 1000);
91+
await db.task.create({ data: { name: 'expired', expiresAt: pastDate } });
92+
await expect(db.task.delete({ where: { id: 1 } })).resolves.toMatchObject({ name: 'expired' });
93+
94+
// create a non-expired task - should NOT be deletable
95+
const futureDate = new Date(Date.now() + 60 * 60 * 1000);
96+
await db.task.create({ data: { name: 'active', expiresAt: futureDate } });
97+
await expect(db.task.delete({ where: { id: 2 } })).toBeRejectedNotFound();
98+
});
99+
100+
it('combines now() default with auth in create policy', async () => {
101+
const db = await createPolicyTestClient(
102+
`
103+
type Auth {
104+
id Int
105+
@@auth
106+
}
107+
108+
model Log {
109+
id Int @id @default(autoincrement())
110+
message String
111+
createdAt DateTime @default(now())
112+
@@allow('create', createdAt <= now() && auth() != null)
113+
@@allow('read', true)
114+
}
115+
`,
116+
);
117+
118+
// anonymous user - rejected
119+
await expect(db.log.create({ data: { message: 'test' } })).toBeRejectedByPolicy();
120+
// authenticated user with auto-filled createdAt - allowed
121+
await expect(db.$setAuth({ id: 1 }).log.create({ data: { message: 'test' } })).resolves.toMatchObject({
122+
message: 'test',
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)