Skip to content

Commit

Permalink
added permissions API, permissions property and defualt middleware logic
Browse files Browse the repository at this point in the history
Signed-off-by: Jim Ezesinachi <[email protected]>
  • Loading branch information
jimezesinachi committed Oct 16, 2024
1 parent c9e0ae4 commit 7834dc6
Show file tree
Hide file tree
Showing 10 changed files with 178 additions and 21 deletions.
56 changes: 50 additions & 6 deletions packages/backend/src/QueryEngine.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { Pool } from 'pg';
import { Query, QueryResult, Table } from '@synthql/queries';
import {
AnyContext,
AnyQuery,
Query,
QueryResult,
Table,
} from '@synthql/queries';
import { QueryPlan, collectLast } from '.';
import { QueryExecutor } from './execution/types';
import { QueryProvider } from './QueryProvider';
import { execute } from './execution/execute';
import { Middleware } from './execution/middleware';
import { middleware, Middleware } from './execution/middleware';
import { PgExecutor } from './execution/executors/PgExecutor';
import { QueryProviderExecutor } from './execution/executors/QueryProviderExecutor';
import { composeQuery } from './execution/executors/PgExecutor/composeQuery';
import { generateLast } from './util/generators/generateLast';
import { SynthqlError } from './SynthqlError';
import { mapRecursive } from './util/tree/mapRecursive';

export interface QueryEngineProps<DB> {
/**
* The database connection string.
*
* e.g. `postgresql://user:password@localhost:5432/db`.
* e.g. `postgresql://user:password@localhost:5432/db`
*
* If you use this option, SynthQL will create
* a conection pool for you internally.
Expand All @@ -39,6 +46,12 @@ export interface QueryEngineProps<DB> {
* ```
*/
prependSql?: string;
/**
* If true, the executor will execute queries that don't
* have the listed permissions in `query.permissions`
* passed via the query context permissions list.
*/
dangerouslyAllowNoPermissions?: boolean;
/**
* A list of middlewares that you want to be used to
* transform any matching queries, before execution.
Expand Down Expand Up @@ -115,11 +128,15 @@ export interface QueryEngineProps<DB> {
logging?: boolean;
}

function isQueryWithPermissions(x: any): x is AnyQuery {
return Array.isArray(x?.permissions);
}

export class QueryEngine<DB> {
private pool: Pool;
private schema: string;
private prependSql?: string;
private middlewares: Array<Middleware>;
private middlewares: Array<Middleware<any, any>>;
private executors: Array<QueryExecutor>;

constructor(config: QueryEngineProps<DB>) {
Expand All @@ -131,7 +148,34 @@ export class QueryEngine<DB> {
connectionString: config.url,
max: 10,
});
this.middlewares = config.middlewares ?? [];
this.middlewares = [
...(config.middlewares ?? []),
middleware<AnyQuery, AnyContext>({
predicate: ({ query, context }) => {
const permissions: string[] = [];

mapRecursive(query, (node) => {
if (isQueryWithPermissions(node)) {
permissions.push(...(node?.permissions ?? []));
}

return node;
});

if (
config.dangerouslyAllowNoPermissions ||
permissions?.every((item) =>
context?.permissions?.includes(item),
)
) {
return true;
} else {
throw SynthqlError.createPermissionsError();
}
},
transformQuery: ({ query }) => query,
}),
];

const qpe = new QueryProviderExecutor(config.providers ?? []);
this.executors = [
Expand All @@ -158,7 +202,7 @@ export class QueryEngine<DB> {
* The name of the database schema to
* execute your SynthQL query against
*
* e.g `public`
* e.g. `public`
*/
schema?: string;
/**
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/SynthqlError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ export class SynthqlError extends Error {

return new SynthqlError(new Error(), type, lines.join('\n'), 404);
}

static createPermissionsError() {
const type = 'PermissionsError';

const lines = [
'A query with a permissions list (ACL) included,',
'is missing matching permissions in the',
'context object permissions list',
];

return new SynthqlError(new Error(), type, lines.join('\n'), 404);
}
}

function printError(err: any): string {
Expand Down
48 changes: 39 additions & 9 deletions packages/backend/src/execution/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { test, describe, expect } from 'vitest';
import { DB, from } from '../tests/generated';
import { Query } from '@synthql/queries';
import { col, Query } from '@synthql/queries';
import { middleware } from './middleware';
import { createQueryEngine } from '../tests/queryEngine';

// Create type/interface for context
type UserRole = 'user' | 'admin' | 'super';
type UserPermission = 'user:read' | 'admin:read' | 'super:read';

interface Session {
id: number;
email: string;
roles: UserRole[];
isActive: boolean;
roles: UserRole[];
permissions: UserPermission[];
}

// Create middleware
Expand All @@ -28,24 +31,28 @@ const restrictPaymentsByCustomer = middleware<Query<DB, 'payment'>, Session>({
}),
});

describe('createExpressSynthqlHandler', async () => {
test('1', async () => {
const queryEngine = createQueryEngine([restrictPaymentsByCustomer]);
describe('middleware', async () => {
test('Query middleware is correctly executed', async () => {
const queryEngine = createQueryEngine({
middlewares: [restrictPaymentsByCustomer],
dangerouslyAllowNoPermissions: false,
});

// Create context
// This would usually be an object generated from a server
// request handler (e.g a parsed cookie/token)
const context: Session = {
id: 1,
email: '[email protected]',
roles: ['user', 'admin', 'super'],
isActive: true,
roles: ['user', 'admin', 'super'],
permissions: ['user:read', 'admin:read', 'super:read'],
};

// Create base query
const q = from('payment').one();
const q = createPaymentQuery().one();

const queryWithContextManuallyAdded = from('payment')
const queryWithContextManuallyAdded = createPaymentQuery()
.where({
customer_id: context.id,
})
Expand All @@ -54,8 +61,31 @@ describe('createExpressSynthqlHandler', async () => {
const result = await queryEngine.executeAndWait(q, { context });

const resultFromQueryWithContextManuallyAdded =
await queryEngine.executeAndWait(queryWithContextManuallyAdded);
await queryEngine.executeAndWait(queryWithContextManuallyAdded, {
context: { permissions: context.permissions },
});

expect(result).toEqual(resultFromQueryWithContextManuallyAdded);
});
});

function createPaymentQuery() {
return from('payment')
.permissions('user:read')
.include({
customer: from('customer')
.permissions('admin:read')
.where({
customer_id: col('payment.customer_id'),
})
.one(),
})
.groupBy(
'amount',
'customer_id',
'payment_date',
'payment_id',
'rental_id',
'staff_id',
);
}
12 changes: 10 additions & 2 deletions packages/backend/src/execution/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export interface Middleware<TQuery = unknown, TContext = unknown> {
import { AnyContext, AnyQuery } from '@synthql/queries';

export interface Middleware<
TQuery extends AnyQuery,
TContext extends AnyContext,
> {
predicate: ({
query,
context,
Expand All @@ -15,7 +20,10 @@ export interface Middleware<TQuery = unknown, TContext = unknown> {
}) => TQuery;
}

export function middleware<TQuery = unknown, TContext = unknown>({
export function middleware<
TQuery extends AnyQuery,
TContext extends AnyContext,
>({
predicate,
transformQuery,
}: {
Expand Down
9 changes: 7 additions & 2 deletions packages/backend/src/tests/queryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ export const pool = new Pool({
'postgres://postgres:postgres@localhost:5432/postgres',
});

export function createQueryEngine(middlewares?: Array<Middleware<any, any>>) {
export function createQueryEngine(data?: {
middlewares?: Array<Middleware<any, any>>;
dangerouslyAllowNoPermissions?: boolean;
}) {
return new QueryEngine<DB>({
pool,
schema: 'public',
middlewares,
middlewares: data?.middlewares,
dangerouslyAllowNoPermissions:
data?.dangerouslyAllowNoPermissions ?? true,
});
}
2 changes: 1 addition & 1 deletion packages/docs/static/reference/assets/navigation.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 7834dc6

Please sign in to comment.