Skip to content

Commit ece062f

Browse files
authored
fix(policy): allow dangerous raw SQL opt-in (#2502)
1 parent c17745d commit ece062f

File tree

4 files changed

+128
-2
lines changed

4 files changed

+128
-2
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type PolicyPluginOptions = {
2+
/**
3+
* Dangerously bypasses access-policy enforcement for raw SQL queries.
4+
* Raw queries remain in the current transaction, but the policy plugin will
5+
* not inspect or reject them.
6+
*/
7+
dangerouslyAllowRawSql?: boolean;
8+
};

packages/plugins/policy/src/plugin.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm';
22
import type { SchemaDef } from '@zenstackhq/orm/schema';
3+
import type { PolicyPluginOptions } from './options';
34
import { check } from './functions';
45
import { PolicyHandler } from './policy-handler';
56

7+
export type { PolicyPluginOptions } from './options';
8+
69
export class PolicyPlugin implements RuntimePlugin<SchemaDef, {}, {}, {}> {
10+
constructor(private readonly options: PolicyPluginOptions = {}) {}
11+
712
get id() {
813
return 'policy' as const;
914
}
@@ -23,7 +28,7 @@ export class PolicyPlugin implements RuntimePlugin<SchemaDef, {}, {}, {}> {
2328
}
2429

2530
onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs<SchemaDef>) {
26-
const handler = new PolicyHandler<SchemaDef>(client);
31+
const handler = new PolicyHandler<SchemaDef>(client, this.options);
2732
return handler.handle(query, proceed);
2833
}
2934
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
OperatorNode,
2424
ParensNode,
2525
PrimitiveValueListNode,
26+
RawNode,
2627
ReferenceNode,
2728
ReturningNode,
2829
SelectAllNode,
@@ -42,6 +43,7 @@ import {
4243
import { match } from 'ts-pattern';
4344
import { ColumnCollector } from './column-collector';
4445
import { ExpressionTransformer } from './expression-transformer';
46+
import type { PolicyPluginOptions } from './options';
4547
import type { Policy, PolicyOperation } from './types';
4648
import {
4749
buildIsFalse,
@@ -67,7 +69,10 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
6769
private readonly dialect: BaseCrudDialect<Schema>;
6870
private readonly eb = expressionBuilder<any, any>();
6971

70-
constructor(private readonly client: ClientContract<Schema>) {
72+
constructor(
73+
private readonly client: ClientContract<Schema>,
74+
private readonly options: PolicyPluginOptions = {},
75+
) {
7176
super();
7277
this.dialect = getCrudDialect(this.client.$schema, this.client.$options);
7378
}
@@ -76,6 +81,9 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
7681

7782
async handle(node: RootOperationNode, proceed: ProceedKyselyQueryFunction) {
7883
if (!this.isCrudQueryNode(node)) {
84+
if (this.options.dangerouslyAllowRawSql && RawNode.is(node as never)) {
85+
return proceed(node);
86+
}
7987
// non-CRUD queries are not allowed
8088
throw createRejectedByPolicyError(
8189
undefined,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
2+
import type { ClientContract } from '@zenstackhq/orm';
3+
import type { SchemaDef } from '@zenstackhq/orm/schema';
4+
import { createTestClient } from '@zenstackhq/testtools';
5+
import { sql } from 'kysely';
6+
import { afterEach, describe, expect, it } from 'vitest';
7+
8+
const schema = `
9+
model User {
10+
id String @id
11+
role String
12+
secrets Secret[]
13+
14+
@@allow('all', true)
15+
}
16+
17+
model Secret {
18+
id String @id
19+
value String
20+
ownerId String
21+
owner User @relation(fields: [ownerId], references: [id])
22+
23+
@@allow('read', auth() != null && auth().role == 'admin')
24+
@@allow('create', auth() != null && auth().role == 'admin')
25+
}
26+
`;
27+
28+
describe('Policy raw SQL tests', () => {
29+
const clients: ClientContract<SchemaDef>[] = [];
30+
31+
afterEach(async () => {
32+
await Promise.all(clients.splice(0).map((client) => client.$disconnect()));
33+
});
34+
35+
function ref(client: ClientContract<SchemaDef>, col: string) {
36+
return client.$schema.provider.type === 'mysql' ? sql.raw(`\`${col}\``) : sql.raw(`"${col}"`);
37+
}
38+
39+
async function createPolicyClient(options?: { dangerouslyAllowRawSql?: boolean; dbName: string }) {
40+
const unsafeClient = await createTestClient(schema, {
41+
dbName: options?.dbName,
42+
plugins: [new PolicyPlugin({ dangerouslyAllowRawSql: options?.dangerouslyAllowRawSql })],
43+
});
44+
clients.push(unsafeClient);
45+
46+
const rawClient = unsafeClient.$unuseAll();
47+
const adminClient = unsafeClient.$setAuth({ id: 'admin', role: 'admin' });
48+
49+
await rawClient.user.create({
50+
data: {
51+
id: 'admin',
52+
role: 'admin',
53+
},
54+
});
55+
56+
return { adminClient };
57+
}
58+
59+
it('keeps rejecting raw SQL by default', async () => {
60+
const { adminClient } = await createPolicyClient({ dbName: 'policy_raw_sql_default' });
61+
62+
await expect(
63+
adminClient.$transaction(async (tx) => {
64+
await tx.secret.create({
65+
data: {
66+
id: 'secret-default',
67+
ownerId: 'admin',
68+
value: 'still-guarded',
69+
},
70+
});
71+
72+
await tx.$queryRaw<{ value: string }[]>`
73+
SELECT ${ref(tx, 'Secret')}.${ref(tx, 'value')}
74+
FROM ${ref(tx, 'Secret')}
75+
WHERE ${ref(tx, 'Secret')}.${ref(tx, 'id')} = ${'secret-default'}
76+
`;
77+
}),
78+
).rejects.toThrow('non-CRUD queries are not allowed');
79+
});
80+
81+
it('allows raw SQL inside a transaction when dangerous raw SQL is enabled', async () => {
82+
const { adminClient } = await createPolicyClient({
83+
dangerouslyAllowRawSql: true,
84+
dbName: 'policy_raw_sql_dangerous',
85+
});
86+
87+
await adminClient.$transaction(async (tx) => {
88+
await tx.secret.create({
89+
data: {
90+
id: 'secret-1',
91+
ownerId: 'admin',
92+
value: 'top-secret',
93+
},
94+
});
95+
96+
const rows = await tx.$queryRaw<{ value: string }[]>`
97+
SELECT ${ref(tx, 'Secret')}.${ref(tx, 'value')}
98+
FROM ${ref(tx, 'Secret')}
99+
WHERE ${ref(tx, 'Secret')}.${ref(tx, 'id')} = ${'secret-1'}
100+
`;
101+
102+
expect(rows).toEqual([{ value: 'top-secret' }]);
103+
});
104+
});
105+
});

0 commit comments

Comments
 (0)