Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions packages/plugins/policy/src/policy-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
AndNode,
BinaryOperationNode,
ColumnNode,
DefaultInsertValueNode,
DeleteQueryNode,
expressionBuilder,
ExpressionWrapper,
Expand All @@ -33,6 +34,7 @@ import {
sql,
TableNode,
UpdateQueryNode,
ValueListNode,
ValueNode,
ValuesNode,
WhereNode,
Expand Down Expand Up @@ -1060,10 +1062,16 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
fields: string[],
isManyToManyJoinTable: boolean,
) {
// Whether row items are operation nodes is determined by the row node's kind:
// `ValueListNode` items are always nodes, `PrimitiveValueListNode` items are always
// raw values. Never inspect an item's shape, because user data (e.g., Json values)
// may legitimately carry a `kind` key.
if (ValuesNode.is(node)) {
return node.values.map((v) => this.unwrapCreateValueRow(v.values, model, fields, isManyToManyJoinTable));
return node.values.map((v) =>
this.unwrapCreateValueRow(v.values, model, fields, isManyToManyJoinTable, ValueListNode.is(v)),
);
} else if (PrimitiveValueListNode.is(node)) {
return [this.unwrapCreateValueRow(node.values, model, fields, isManyToManyJoinTable)];
return [this.unwrapCreateValueRow(node.values, model, fields, isManyToManyJoinTable, false)];
} else {
invariant(false, `Unexpected node kind: ${node.kind} for unwrapping create values`);
}
Expand All @@ -1074,27 +1082,25 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
model: string,
fields: string[],
isImplicitManyToManyJoinTable: boolean,
itemsAreNodes: boolean,
) {
invariant(data.length === fields.length, 'data length must match fields length');
const result: { node: OperationNode; raw: unknown }[] = [];
for (let i = 0; i < data.length; i++) {
const item = data[i]!;
if (typeof item === 'object' && item && 'kind' in item) {
if (item.kind === 'DefaultInsertValueNode') {
if (itemsAreNodes) {
const itemNode = item as OperationNode;
if (DefaultInsertValueNode.is(itemNode)) {
result.push({ node: ValueNode.create(null), raw: null });
continue;
}
const fieldDef = QueryUtils.requireField(this.client.$schema, model, fields[i]!);
invariant(item.kind === 'ValueNode', 'expecting a ValueNode');
invariant(ValueNode.is(itemNode), `expecting a ValueNode, got ${itemNode.kind}`);
result.push({
node: ValueNode.create(
this.dialect.transformInput(
(item as ValueNode).value,
fieldDef.type as BuiltinType,
!!fieldDef.array,
),
this.dialect.transformInput(itemNode.value, fieldDef.type as BuiltinType, !!fieldDef.array),
),
raw: (item as ValueNode).value,
raw: itemNode.value,
});
} else {
let value: unknown = item;
Expand Down
82 changes: 82 additions & 0 deletions tests/regression/test/issue-2791.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { createPolicyTestClient } from '@zenstackhq/testtools';
import { describe, expect, it } from 'vitest';

// https://github.com/zenstackhq/zenstack/issues/2791
// Policy pre-create checks unwrap insert values and used to treat any object with a
// `kind` key as a Kysely operation node. A Json payload like `{ kind: "artwork" }`
// then failed with `Invariant failed: expecting a ValueNode`.
const schema = `
model User {
id Int @id @default(autoincrement())
}

model Item {
id Int @id @default(autoincrement())
payload Json

// non-constant: constant @@allow('all', true) skips pre-create value unwrapping
@@allow('all', auth() == null)
}
`;

describe('Regression for issue 2791', () => {
it('creates a Json value that has a top-level kind key', async () => {
const db = await createPolicyTestClient(schema, { provider: 'postgresql' });

await expect(db.item.create({ data: { payload: { kind: 'artwork' } } })).resolves.toMatchObject({
payload: { kind: 'artwork' },
});
});

it('creates many Json values that have a top-level kind key', async () => {
const db = await createPolicyTestClient(schema, { provider: 'postgresql' });

await expect(
db.item.createMany({ data: [{ payload: { kind: 'artwork' } }, { payload: { kind: 'photo' } }] }),
).resolves.toMatchObject({ count: 2 });

await expect(db.item.findMany()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ payload: { kind: 'artwork' } }),
expect.objectContaining({ payload: { kind: 'photo' } }),
]),
);
});

it('does not mistake a Json value for a Kysely node when its kind matches a node name', async () => {
const db = await createPolicyTestClient(schema, { provider: 'postgresql' });

// if the payload were treated as a `ValueNode`, the policy check would unwrap
// `value` and the stored payload would not round-trip
await expect(db.item.create({ data: { payload: { kind: 'ValueNode', value: 'x' } } })).resolves.toMatchObject({
payload: { kind: 'ValueNode', value: 'x' },
});

await expect(db.item.create({ data: { payload: { kind: 'DefaultInsertValueNode' } } })).resolves.toMatchObject({
payload: { kind: 'DefaultInsertValueNode' },
});
});

it('still rejects creates that violate the policy regardless of Json shape', async () => {
const db = await createPolicyTestClient(schema, { provider: 'postgresql' });
const authDb = db.$setAuth({ id: 1 });

await expect(authDb.item.create({ data: { payload: { kind: 'artwork' } } })).toBeRejectedByPolicy();
await expect(
authDb.item.create({ data: { payload: { kind: 'ValueNode', value: 'x' } } }),
).toBeRejectedByPolicy();
await expect(
authDb.item.createMany({ data: [{ payload: { kind: 'artwork' } }, { payload: { kind: 'photo' } }] }),
).toBeRejectedByPolicy();
await expect(db.item.findMany()).resolves.toHaveLength(0);
});

it('still accepts the same Json payload via update', async () => {
const db = await createPolicyTestClient(schema, { provider: 'postgresql' });

const item = await db.item.create({ data: { payload: {} } });
await expect(
db.item.update({ where: { id: item.id }, data: { payload: { kind: 'artwork' } } }),
).resolves.toMatchObject({ payload: { kind: 'artwork' } });
});
});
Loading