Skip to content

Commit

Permalink
fixes & tests
Browse files Browse the repository at this point in the history
Signed-off-by: Jim Ezesinachi <ezesinachijim@gmail.com>
  • Loading branch information
jimezesinachi committed Oct 17, 2024
1 parent 7834dc6 commit 43bcf37
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 55 deletions.
52 changes: 8 additions & 44 deletions packages/backend/src/QueryEngine.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import { Pool } from 'pg';
import {
AnyContext,
AnyQuery,
Query,
QueryResult,
Table,
} from '@synthql/queries';
import { 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, Middleware } from './execution/middleware';
import { Middleware, aclMiddleware } 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> {
/**
Expand Down Expand Up @@ -51,7 +44,7 @@ export interface QueryEngineProps<DB> {
* have the listed permissions in `query.permissions`
* passed via the query context permissions list.
*/
dangerouslyAllowNoPermissions?: boolean;
dangerouslyIgnorePermissions?: boolean;
/**
* A list of middlewares that you want to be used to
* transform any matching queries, before execution.
Expand Down Expand Up @@ -128,10 +121,6 @@ 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;
Expand All @@ -148,34 +137,9 @@ export class QueryEngine<DB> {
connectionString: config.url,
max: 10,
});
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,
}),
];
this.middlewares = config.dangerouslyIgnorePermissions
? config.middlewares ?? []
: [...(config.middlewares ?? []), aclMiddleware];

const qpe = new QueryProviderExecutor(config.providers ?? []);
this.executors = [
Expand Down Expand Up @@ -218,12 +182,12 @@ export class QueryEngine<DB> {
if (
middleware.predicate({
query,
context: opts?.context,
context: opts?.context ?? {},
})
) {
transformedQuery = middleware.transformQuery({
query: transformedQuery,
context: opts?.context,
context: opts?.context ?? {},
});
}
}
Expand Down
26 changes: 20 additions & 6 deletions packages/backend/src/SynthqlError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class SynthqlError extends Error {
'',
JSON.stringify(query, null, 2),
'',
'Check your query and make sure you have `read` access to all included',
'Check your query and make sure you have`read` access to all included',
'tables and columns, and have registered all queries via the QueryEngine',
];

Expand Down Expand Up @@ -136,16 +136,30 @@ export class SynthqlError extends Error {
return new SynthqlError(new Error(), type, lines.join('\n'), 404);
}

static createPermissionsError() {
static createPermissionsError({
node,
missingPermissions,
contextPermissions,
}: {
node: AnyQuery;
missingPermissions: string[];
contextPermissions: string[];
}) {
const type = 'PermissionsError';

const lines = [
'A query with a permissions list (ACL) included,',
'is missing matching permissions in the',
'context object permissions list',
`The query '${node?.name}' with a permissions list (ACL) included:`,
`${JSON.stringify(node?.permissions, null, 2)}`,
'',
'is missing the required matching permissions:',
`${JSON.stringify(missingPermissions, null, 2)}`,
'',
'in the passed context object permissions list:',
`${JSON.stringify(contextPermissions, null, 2)}`,
'',
];

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

Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/execution/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('middleware', async () => {
test('Query middleware is correctly executed', async () => {
const queryEngine = createQueryEngine({
middlewares: [restrictPaymentsByCustomer],
dangerouslyAllowNoPermissions: false,
dangerouslyIgnorePermissions: false,
});

// Create context
Expand Down
38 changes: 38 additions & 0 deletions packages/backend/src/execution/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { AnyContext, AnyQuery } from '@synthql/queries';
import { mapRecursive } from '../util/tree/mapRecursive';
import { SynthqlError } from '../SynthqlError';

export interface Middleware<
TQuery extends AnyQuery,
Expand Down Expand Up @@ -47,3 +49,39 @@ export function middleware<
transformQuery,
};
}

export const aclMiddleware = middleware<AnyQuery, AnyContext>({
predicate: ({ query, context }) => {
const missingPermissions: string[] = [];

mapRecursive(query, (node) => {
if (isQueryWithPermissions(node)) {
if (
!(node?.permissions ?? []).every((item) => {
if (context?.permissions?.includes(item)) {
return true;
} else {
missingPermissions.push(item);
return false;
}
})
) {
throw SynthqlError.createPermissionsError({
node,
missingPermissions,
contextPermissions: context?.permissions ?? [],
});
}
}

return node;
});

return true;
},
transformQuery: ({ query }) => query,
});

function isQueryWithPermissions(x: any): x is AnyQuery {
return Array.isArray(x?.permissions);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect } from 'vitest';
import { test } from '@fast-check/vitest';
import { AnyQuery, Query } from '@synthql/queries';
import { ArbitraryQueryBuilder } from '../arbitraries/ArbitraryQueryBuilder';
import { createQueryEngine } from '../../queryEngine';
import { DB } from '../../generated';

const queryBuilder = ArbitraryQueryBuilder.fromPagila();
const queryEngine = createQueryEngine({
dangerouslyIgnorePermissions: false,
});

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

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

// 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: 'user@example.com',
isActive: true,
roles: ['user', 'admin', 'super'],
permissions: ['user:read', 'admin:read', 'super:read'],
};

describe('Property based tests for permissions', () => {
const numRuns = 100;
const timeout = numRuns * 1000;
const endOnFailure = true;

test.prop(
[queryBuilder.withCardinality('many').withSomeResults().build()],
{
verbose: true,
numRuns,
endOnFailure,
},
)(
[
'A query with permissions will fail unless all permissions are met',
].join(''),
async (query) => {
const permissionedQuery: Query<DB> = {
...query,
permissions: context.permissions,
name: defaultName(query),
};

expect(
async () => await queryEngine.executeAndWait(permissionedQuery),
).rejects.toThrow(
`The query '${permissionedQuery.name}' with a permissions list (ACL) included:`,
);

// Here we check if it errors when some,
// but not all the permissions, are met
expect(
async () =>
await queryEngine.executeAndWait(permissionedQuery, {
context: { permissions: [context.permissions[0]] },
}),
).rejects.toThrow(
`The query '${permissionedQuery.name}' with a permissions list (ACL) included:`,
);
},
timeout,
);

test.prop(
[queryBuilder.withCardinality('many').withSomeResults().build()],
{
verbose: true,
numRuns,
endOnFailure,
},
)(
[
'A query with no permissions will never fail for permission issues',
].join(''),
async (query) => {
expect(
async () => await queryEngine.executeAndWait(query),
).not.toThrow();
},
timeout,
);
});

function defaultName(query: AnyQuery) {
const whereName = Object.keys(query.where).join('-and-');

if (whereName === '') {
return `${query.from}-all`;
}

return `${query.from}-by-${whereName}`;
}
6 changes: 3 additions & 3 deletions packages/backend/src/tests/queryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export const pool = new Pool({

export function createQueryEngine(data?: {
middlewares?: Array<Middleware<any, any>>;
dangerouslyAllowNoPermissions?: boolean;
dangerouslyIgnorePermissions?: boolean;
}) {
return new QueryEngine<DB>({
pool,
schema: 'public',
middlewares: data?.middlewares,
dangerouslyAllowNoPermissions:
data?.dangerouslyAllowNoPermissions ?? true,
dangerouslyIgnorePermissions:
data?.dangerouslyIgnorePermissions ?? true,
});
}
Loading

0 comments on commit 43bcf37

Please sign in to comment.