Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 6 additions & 2 deletions packages/plugins/policy/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { type OnKyselyQueryArgs, type RuntimePlugin } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { check } from './functions';
import { PolicyHandler } from './policy-handler';
import { PolicyHandler, type PolicyHandlerOptions } from './policy-handler';

export type PolicyPluginOptions = PolicyHandlerOptions;

export class PolicyPlugin implements RuntimePlugin<SchemaDef, {}, {}, {}> {
constructor(private readonly options: PolicyPluginOptions = {}) {}

get id() {
return 'policy' as const;
}
Expand All @@ -23,7 +27,7 @@ export class PolicyPlugin implements RuntimePlugin<SchemaDef, {}, {}, {}> {
}

onKyselyQuery({ query, client, proceed }: OnKyselyQueryArgs<SchemaDef>) {
const handler = new PolicyHandler<SchemaDef>(client);
const handler = new PolicyHandler<SchemaDef>(client, this.options);
return handler.handle(query, proceed);
}
}
18 changes: 17 additions & 1 deletion packages/plugins/policy/src/policy-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
OperatorNode,
ParensNode,
PrimitiveValueListNode,
RawNode,
ReferenceNode,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ReturningNode,
SelectAllNode,
Expand Down Expand Up @@ -63,11 +64,23 @@ export type MutationQueryNode = InsertQueryNode | UpdateQueryNode | DeleteQueryN

type FieldLevelPolicyOperations = Exclude<CRUD_EXT, 'create' | 'delete'>;

export type PolicyHandlerOptions = {
/**
* Dangerously bypasses access-policy enforcement for raw SQL queries.
* Raw queries remain in the current transaction, but the policy plugin will
* not inspect or reject them.
*/
dangerouslyAllowRawSql?: boolean;
};

export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransformer {
private readonly dialect: BaseCrudDialect<Schema>;
private readonly eb = expressionBuilder<any, any>();

constructor(private readonly client: ClientContract<Schema>) {
constructor(
private readonly client: ClientContract<Schema>,
private readonly options: PolicyHandlerOptions = {},
) {
super();
this.dialect = getCrudDialect(this.client.$schema, this.client.$options);
}
Expand All @@ -76,6 +89,9 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf

async handle(node: RootOperationNode, proceed: ProceedKyselyQueryFunction) {
if (!this.isCrudQueryNode(node)) {
if (this.options.dangerouslyAllowRawSql && RawNode.is(node as never)) {
return proceed(node);
}
// non-CRUD queries are not allowed
throw createRejectedByPolicyError(
undefined,
Expand Down
105 changes: 105 additions & 0 deletions tests/e2e/orm/policy/raw-sql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { createTestClient } from '@zenstackhq/testtools';
import { sql } from 'kysely';
import { afterEach, describe, expect, it } from 'vitest';

const schema = `
model User {
id String @id
role String
secrets Secret[]

@@allow('all', true)
}

model Secret {
id String @id
value String
ownerId String
owner User @relation(fields: [ownerId], references: [id])

@@allow('read', auth() != null && auth().role == 'admin')
@@allow('create', auth() != null && auth().role == 'admin')
}
`;

describe('Policy raw SQL tests', () => {
const clients: ClientContract<SchemaDef>[] = [];

afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.$disconnect()));
});

function ref(client: ClientContract<SchemaDef>, col: string) {
return client.$schema.provider.type === 'mysql' ? sql.raw(`\`${col}\``) : sql.raw(`"${col}"`);
}

async function createPolicyClient(options?: { dangerouslyAllowRawSql?: boolean; dbName: string }) {
const unsafeClient = await createTestClient(schema, {
dbName: options?.dbName,
plugins: [new PolicyPlugin({ dangerouslyAllowRawSql: options?.dangerouslyAllowRawSql })],
});
clients.push(unsafeClient);

const rawClient = unsafeClient.$unuseAll();
const adminClient = unsafeClient.$setAuth({ id: 'admin', role: 'admin' });

await rawClient.user.create({
data: {
id: 'admin',
role: 'admin',
},
});

return { adminClient };
}

it('keeps rejecting raw SQL by default', async () => {
const { adminClient } = await createPolicyClient({ dbName: 'policy_raw_sql_default' });

await expect(
adminClient.$transaction(async (tx) => {
await tx.secret.create({
data: {
id: 'secret-default',
ownerId: 'admin',
value: 'still-guarded',
},
});

await tx.$queryRaw<{ value: string }[]>`
SELECT ${ref(tx, 'Secret')}.${ref(tx, 'value')}
FROM ${ref(tx, 'Secret')}
WHERE ${ref(tx, 'Secret')}.${ref(tx, 'id')} = ${'secret-default'}
`;
}),
).rejects.toThrow('non-CRUD queries are not allowed');
});

it('allows raw SQL inside a transaction when dangerous raw SQL is enabled', async () => {
const { adminClient } = await createPolicyClient({
dangerouslyAllowRawSql: true,
dbName: 'policy_raw_sql_dangerous',
});

await adminClient.$transaction(async (tx) => {
await tx.secret.create({
data: {
id: 'secret-1',
ownerId: 'admin',
value: 'top-secret',
},
});

const rows = await tx.$queryRaw<{ value: string }[]>`
SELECT ${ref(tx, 'Secret')}.${ref(tx, 'value')}
FROM ${ref(tx, 'Secret')}
WHERE ${ref(tx, 'Secret')}.${ref(tx, 'id')} = ${'secret-1'}
`;

expect(rows).toEqual([{ value: 'top-secret' }]);
});
});
});
Loading