Skip to content

Commit 1cf4345

Browse files
ymc9claude
andauthored
fix(policy): join base table when loading before-update entities for @@DeleGate sub-models (#2605)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fd8db57 commit 1cf4345

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

packages/plugins/policy/src/policy-handler.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,11 +587,42 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
587587
const combinedFilter = where ? conjunction(this.dialect, [where.where, policyFilter]) : policyFilter;
588588
const selections = beforeUpdateAccessFields ?? QueryUtils.requireIdFields(this.client.$schema, model);
589589

590+
// Always qualify each column with its owning table. For delegate sub-models,
591+
// fields inherited from the base live in the base table and must be joined in.
592+
const baseModelsToJoin = new Set<string>();
593+
const selectionNodes = selections.map((f) => {
594+
const fieldDef = QueryUtils.getField(this.client.$schema, model, f);
595+
const owningTable = fieldDef?.originModel ?? model;
596+
if (fieldDef?.originModel) {
597+
baseModelsToJoin.add(fieldDef.originModel);
598+
}
599+
return SelectionNode.create(ReferenceNode.create(ColumnNode.create(f), TableNode.create(owningTable)));
600+
});
601+
602+
const idFields = QueryUtils.requireIdFields(this.client.$schema, model);
603+
const joins: JoinNode[] = Array.from(baseModelsToJoin).map((baseModel) =>
604+
JoinNode.createWithOn(
605+
'LeftJoin',
606+
TableNode.create(baseModel),
607+
conjunction(
608+
this.dialect,
609+
idFields.map((idField) =>
610+
BinaryOperationNode.create(
611+
ReferenceNode.create(ColumnNode.create(idField), TableNode.create(model)),
612+
OperatorNode.create('='),
613+
ReferenceNode.create(ColumnNode.create(idField), TableNode.create(baseModel)),
614+
),
615+
),
616+
),
617+
),
618+
);
619+
590620
const query: SelectQueryNode = {
591621
kind: 'SelectQueryNode',
592622
from: FromNode.create([TableNode.create(model)]),
623+
joins: joins.length > 0 ? joins : undefined,
593624
where: WhereNode.create(combinedFilter),
594-
selections: selections.map((f) => SelectionNode.create(ColumnNode.create(f))),
625+
selections: selectionNodes,
595626
};
596627
const result = await proceed(query);
597628
return { fields: beforeUpdateAccessFields, rows: result.rows };
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createPolicyTestClient } from '@zenstackhq/testtools';
2+
import { describe, expect, it } from 'vitest';
3+
4+
// https://github.com/zenstackhq/zenstack/issues/2595
5+
describe('Regression for issue #2595', () => {
6+
const schema = `
7+
model Transaction {
8+
id String @id @default(cuid())
9+
variant String
10+
status String @default("Draft")
11+
12+
@@delegate(variant)
13+
@@allow('all', true)
14+
@@deny('post-update', before().status == 'Finalized' && status == 'Draft')
15+
}
16+
17+
model Invoice extends Transaction {
18+
invoiceNumber String?
19+
}
20+
`;
21+
22+
it('update on delegate sub-model with before() policy on base field does not error', async () => {
23+
const db = await createPolicyTestClient(schema);
24+
25+
const invoice = await db.invoice.create({ data: {} });
26+
27+
// Should succeed: status is Draft, so the post-update deny rule doesn't fire
28+
const updated = await db.invoice.update({
29+
where: { id: invoice.id },
30+
data: { invoiceNumber: 'INV-001' },
31+
});
32+
33+
expect(updated.invoiceNumber).toBe('INV-001');
34+
});
35+
36+
it('update is denied when before().status is Finalized', async () => {
37+
const db = await createPolicyTestClient(schema);
38+
39+
const invoice = await db.invoice.create({ data: { status: 'Finalized' } });
40+
41+
// Should be denied: before().status == 'Finalized' && status == 'Draft' would
42+
// become true if update changes status back to Draft
43+
await expect(
44+
db.invoice.update({
45+
where: { id: invoice.id },
46+
data: { status: 'Draft' },
47+
}),
48+
).toBeRejectedByPolicy();
49+
});
50+
});

0 commit comments

Comments
 (0)