Skip to content

Commit

Permalink
feat: permissions (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
jimezesinachi authored Oct 21, 2024
1 parent c9e0ae4 commit 2794e29
Show file tree
Hide file tree
Showing 11 changed files with 347 additions and 23 deletions.
22 changes: 15 additions & 7 deletions packages/backend/src/QueryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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, permissionsMiddleware } from './execution/middleware';
import { PgExecutor } from './execution/executors/PgExecutor';
import { QueryProviderExecutor } from './execution/executors/QueryProviderExecutor';
import { composeQuery } from './execution/executors/PgExecutor/composeQuery';
Expand All @@ -15,7 +15,7 @@ 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 +39,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.
*/
dangerouslyIgnorePermissions?: boolean;
/**
* A list of middlewares that you want to be used to
* transform any matching queries, before execution.
Expand Down Expand Up @@ -119,7 +125,7 @@ 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 +137,9 @@ export class QueryEngine<DB> {
connectionString: config.url,
max: 10,
});
this.middlewares = config.middlewares ?? [];
this.middlewares = config.dangerouslyIgnorePermissions
? config.middlewares ?? []
: [...(config.middlewares ?? []), permissionsMiddleware];

const qpe = new QueryProviderExecutor(config.providers ?? []);
this.executors = [
Expand All @@ -158,7 +166,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 All @@ -174,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
28 changes: 27 additions & 1 deletion 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 @@ -135,6 +135,31 @@ export class SynthqlError extends Error {

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

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

const lines = [
`The query ${query?.name} is missing the following permissions: ${missingPermissions.join(', ')}`,
'',
'in the passed context permissions list:',
`${JSON.stringify(contextPermissions, null, 2)}`,
'',
'from its list of required permissions defined:',
`${JSON.stringify(query?.permissions, null, 2)}`,
'',
];

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

function printError(err: any): string {
Expand All @@ -161,6 +186,7 @@ function composeMessage(err: any, props: SqlExecutionErrorProps): string {
'',
'And which was composed from the following SynthQL query:',
JSON.stringify(props.query, null, 2),
'',
];

return lines.join('\n');
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],
dangerouslyIgnorePermissions: 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: 'user@example.com',
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',
);
}
54 changes: 52 additions & 2 deletions packages/backend/src/execution/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export interface Middleware<TQuery = unknown, TContext = unknown> {
import { AnyContext, AnyQuery } from '@synthql/queries';
import { mapRecursive } from '../util/tree/mapRecursive';
import { SynthqlError } from '../SynthqlError';

export interface Middleware<
TQuery extends AnyQuery,
TContext extends AnyContext,
> {
predicate: ({
query,
context,
Expand All @@ -15,7 +22,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 All @@ -39,3 +49,43 @@ export function middleware<TQuery = unknown, TContext = unknown>({
transformQuery,
};
}

export const permissionsMiddleware = middleware<AnyQuery, AnyContext>({
predicate: () => true,
transformQuery: ({ query, context }) => {
throwIfPermissionsMissing(query, context?.permissions);

return query;
},
});

function throwIfPermissionsMissing(
query: AnyQuery,
contextPermissions: AnyContext['permissions'] = [],
) {
mapRecursive(query, (node) => {
if (isQueryWithPermissions(node)) {
const missingPermissions = node?.permissions
? node?.permissions.filter(
(permission) => !contextPermissions.includes(permission),
)
: [];

if (missingPermissions.length > 0) {
throw SynthqlError.createPermissionsError({
query: node,
missingPermissions,
contextPermissions,
});
}
}

return node;
});
}

function isQueryWithPermissions(
x: any,
): x is AnyQuery & { permissions: string[] } {
return Array.isArray(x?.permissions);
}
Loading

0 comments on commit 2794e29

Please sign in to comment.