Skip to content

Commit 198c352

Browse files
authored
feat(policy): post-update policies (#275)
1 parent 3b13b72 commit 198c352

23 files changed

+570
-78
lines changed

TODO.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
- [ ] Short-circuit pre-create check for scalar-field only policies
102102
- [x] Inject "on conflict do update"
103103
- [x] `check` function
104+
- [ ] Accessing tables not in the schema
104105
- [x] Migration
105106
- [ ] Databases
106107
- [x] SQLite

packages/language/res/stdlib.zmodel

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@ attribute @@@deprecated(_ message: String)
666666
* @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations.
667667
* @param condition: a boolean expression that controls if the operation should be allowed.
668668
*/
669-
attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean)
669+
attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'post-update'","'delete'", "'all'"]), _ condition: Boolean)
670670

671671
/**
672672
* Defines an access policy that allows the annotated field to be read or updated.
@@ -684,7 +684,7 @@ attribute @allow(_ operation: String @@@completionHint(["'create'", "'read'", "'
684684
* @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations.
685685
* @param condition: a boolean expression that controls if the operation should be denied.
686686
*/
687-
attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean)
687+
attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'post-update'","'delete'", "'all'"]), _ condition: Boolean)
688688

689689
/**
690690
* Defines an access policy that denies the annotated field to be read or updated.
@@ -705,8 +705,8 @@ function check(field: Any, operation: String?): Boolean {
705705
} @@@expressionContext([AccessPolicy])
706706

707707
/**
708-
* Gets entities value before an update. Only valid when used in a "update" policy rule.
708+
* Gets entity's value before an update. Only valid when used in a "post-update" policy rule.
709709
*/
710-
function future(): Any {
710+
function before(): Any {
711711
} @@@expressionContext([AccessPolicy])
712712

packages/language/src/utils.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,6 @@ export function isRelationshipField(field: DataField) {
145145
return isDataModel(field.type.reference?.ref);
146146
}
147147

148-
export function isFutureExpr(node: AstNode) {
149-
return isInvocationExpr(node) && node.function.ref?.name === 'future' && isFromStdlib(node.function.ref);
150-
}
151-
152148
export function isDelegateModel(node: AstNode) {
153149
return isDataModel(node) && hasAttribute(node, '@@delegate');
154150
}
@@ -450,8 +446,8 @@ export function getAuthDecl(decls: (DataModel | TypeDef)[]) {
450446
return authModel;
451447
}
452448

453-
export function isFutureInvocation(node: AstNode) {
454-
return isInvocationExpr(node) && node.function.ref?.name === 'future' && isFromStdlib(node.function.ref);
449+
export function isBeforeInvocation(node: AstNode) {
450+
return isInvocationExpr(node) && node.function.ref?.name === 'before' && isFromStdlib(node.function.ref);
455451
}
456452

457453
export function isCollectionPredicate(node: AstNode): node is BinaryExpr {

packages/language/src/validators/attribute-application-validator.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import {
2323
getAllAttributes,
2424
getStringLiteral,
2525
isAuthOrAuthMemberAccess,
26+
isBeforeInvocation,
2627
isCollectionPredicate,
2728
isDataFieldReference,
2829
isDelegateModel,
29-
isFutureExpr,
3030
isRelationshipField,
3131
mapBuiltinTypeToExpressionType,
3232
resolved,
@@ -166,13 +166,20 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
166166
});
167167
return;
168168
}
169-
this.validatePolicyKinds(kind, ['create', 'read', 'update', 'delete', 'all'], attr, accept);
169+
this.validatePolicyKinds(kind, ['create', 'read', 'update', 'post-update', 'delete', 'all'], attr, accept);
170170

171171
if ((kind === 'create' || kind === 'all') && attr.args[1]?.value) {
172172
// "create" rules cannot access non-owned relations because the entity does not exist yet, so
173173
// there can't possibly be a fk that points to it
174174
this.rejectNonOwnedRelationInExpression(attr.args[1].value, accept);
175175
}
176+
177+
if (kind !== 'post-update' && attr.args[1]?.value) {
178+
const beforeCall = AstUtils.streamAst(attr.args[1]?.value).find(isBeforeInvocation);
179+
if (beforeCall) {
180+
accept('error', `"before()" is only allowed in "post-update" policy rules`, { node: beforeCall });
181+
}
182+
}
176183
}
177184

178185
private rejectNonOwnedRelationInExpression(expr: Expression, accept: ValidationAcceptor) {
@@ -251,8 +258,8 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
251258
const kindItems = this.validatePolicyKinds(kind, ['read', 'update', 'all'], attr, accept);
252259

253260
const expr = attr.args[1]?.value;
254-
if (expr && AstUtils.streamAst(expr).some((node) => isFutureExpr(node))) {
255-
accept('error', `"future()" is not allowed in field-level policy rules`, { node: expr });
261+
if (expr && AstUtils.streamAst(expr).some((node) => isBeforeInvocation(node))) {
262+
accept('error', `"before()" is not allowed in field-level policy rules`, { node: expr });
256263
}
257264

258265
// 'update' rules are not allowed for relation fields

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import {
1111
isNullExpr,
1212
isReferenceExpr,
1313
isThisExpr,
14+
MemberAccessExpr,
1415
type ExpressionType,
1516
} from '../generated/ast';
1617

1718
import {
1819
findUpAst,
1920
isAuthInvocation,
2021
isAuthOrAuthMemberAccess,
22+
isBeforeInvocation,
2123
isDataFieldReference,
2224
isEnumFieldReference,
2325
typeAssignable,
@@ -59,12 +61,21 @@ export default class ExpressionValidator implements AstValidator<Expression> {
5961

6062
// extra validations by expression type
6163
switch (expr.$type) {
64+
case 'MemberAccessExpr':
65+
this.validateMemberAccessExpr(expr, accept);
66+
break;
6267
case 'BinaryExpr':
6368
this.validateBinaryExpr(expr, accept);
6469
break;
6570
}
6671
}
6772

73+
private validateMemberAccessExpr(expr: MemberAccessExpr, accept: ValidationAcceptor) {
74+
if (isBeforeInvocation(expr.operand) && isDataModel(expr.$resolvedType?.decl)) {
75+
accept('error', 'relation fields cannot be accessed from `before()`', { node: expr });
76+
}
77+
}
78+
6879
private validateBinaryExpr(expr: BinaryExpr, accept: ValidationAcceptor) {
6980
switch (expr.operator) {
7081
case 'in': {

packages/language/src/zmodel-linker.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
getAuthDecl,
5858
getContainingDataModel,
5959
isAuthInvocation,
60-
isFutureExpr,
60+
isBeforeInvocation,
6161
isMemberContainer,
6262
mapBuiltinTypeToExpressionType,
6363
} from './utils';
@@ -292,8 +292,8 @@ export class ZModelLinker extends DefaultLinker {
292292
if (authDecl) {
293293
node.$resolvedType = { decl: authDecl, nullable: true };
294294
}
295-
} else if (isFutureExpr(node)) {
296-
// future() function is resolved to current model
295+
} else if (isBeforeInvocation(node)) {
296+
// before() function is resolved to current model
297297
node.$resolvedType = { decl: getContainingDataModel(node) };
298298
} else {
299299
this.resolveToDeclaredType(node, funcDecl.returnType);

packages/language/src/zmodel-scope.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
getRecursiveBases,
3838
isAuthInvocation,
3939
isCollectionPredicate,
40-
isFutureInvocation,
40+
isBeforeInvocation,
4141
resolveImportUri,
4242
} from './utils';
4343

@@ -170,8 +170,8 @@ export class ZModelScopeProvider extends DefaultScopeProvider {
170170
return this.createScopeForAuth(node, globalScope);
171171
}
172172

173-
if (isFutureInvocation(operand)) {
174-
// resolve `future()` to the containing model
173+
if (isBeforeInvocation(operand)) {
174+
// resolve `before()` to the containing model
175175
return this.createScopeForContainingModel(node, globalScope);
176176
}
177177
return EMPTY_SCOPE;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, it } from 'vitest';
2+
import { loadSchemaWithError } from './utils';
3+
4+
describe('Attribute application validation tests', () => {
5+
it('rejects before in non-post-update policies', async () => {
6+
await loadSchemaWithError(
7+
`
8+
datasource db {
9+
provider = 'sqlite'
10+
url = 'file:./dev.db'
11+
}
12+
13+
model Foo {
14+
id Int @id @default(autoincrement())
15+
x Int
16+
@@allow('all', true)
17+
@@deny('update', before(x) > 2)
18+
}
19+
`,
20+
`"before()" is only allowed in "post-update" policy rules`,
21+
);
22+
});
23+
});

packages/runtime/src/client/contract.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,21 @@ export interface ClientConstructor {
213213
*/
214214
export type CRUD = 'create' | 'read' | 'update' | 'delete';
215215

216+
/**
217+
* Extended CRUD operations including 'post-update'.
218+
*/
219+
export type CRUD_EXT = CRUD | 'post-update';
220+
216221
/**
217222
* CRUD operations.
218223
*/
219224
export const CRUD = ['create', 'read', 'update', 'delete'] as const;
220225

226+
/**
227+
* Extended CRUD operations including 'post-update'.
228+
*/
229+
export const CRUD_EXT = [...CRUD, 'post-update'] as const;
230+
221231
//#region Model operations
222232

223233
export type AllModelOperations<Schema extends SchemaDef, Model extends GetModels<Schema>> = {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,8 +1296,9 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
12961296
return { count: Number(result.numAffectedRows) } as Result;
12971297
} else {
12981298
const idFields = requireIdFields(this.schema, model);
1299-
const result = await query.returning(idFields as any).execute();
1300-
return result as Result;
1299+
const finalQuery = query.returning(idFields as any);
1300+
const result = await this.executeQuery(kysely, finalQuery, 'update');
1301+
return result.rows as Result;
13011302
}
13021303
}
13031304

0 commit comments

Comments
 (0)