-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
docs: added sections on security (#57)
- Loading branch information
Showing
6 changed files
with
291 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
# Query middlewares | ||
|
||
Query middlewares are functions that are executed before a query is executed. They can be used to add additional functionality to the query, such as logging, caching, or authentication. | ||
|
||
In the context of security, query middlewares can be used to add additional checks on every query, or add additional filters to the query to limit the result set. | ||
|
||
## Adding a middleware | ||
|
||
You can add a middleware to the query engine using the `.use()` method. | ||
|
||
```ts | ||
import { DB } from './db'; | ||
import { QueryEngine, mapQuery } from '@synthql/backend'; | ||
import { orders } from './queries'; | ||
|
||
const restrictOrdersByUser = middleware<DB>() | ||
.from('orders') | ||
.mapQuery((query, context) => { | ||
const userId = context.user.id; | ||
return { | ||
context, | ||
query: { | ||
...query, | ||
// transforms the `where` to ensure that only orders can be read from the | ||
// current user. | ||
where: { | ||
...query.where, | ||
user_id: userId, | ||
}, | ||
}, | ||
}; | ||
}); | ||
|
||
const queryEngine = new QueryEngine<DB>({ | ||
middlewares: [restrictOrdersByUser], | ||
}); | ||
``` | ||
|
||
## When are middlewares executed? | ||
|
||
When a query is executed, the ID check is performed first, and then the parameters are substituted. Then the middleware is executed. | ||
|
||
This ensures that the middleware can inject additional parameters to the query as it's now happening in a safe context. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
# Query permissions | ||
|
||
SynthQL uses a declarative approach to define what permissions are required to run a query. This approach is both simple and powerful, and makes it easy to understand what permissions are required to run a query. | ||
|
||
With SynthQL you don't need to sprinkle your code with permission assertions or `if` conditions to check for permissions, instead you define on a per-query basis what permissions are required to run the query, and the QueryEngine will take care of the rest. | ||
|
||
## Defining permissions | ||
|
||
The `.requires()` method is used to define what permissions are required to run a query. | ||
|
||
```ts | ||
from('users') | ||
.requires('users:read') | ||
.all() | ||
``` | ||
|
||
The `.requires(...roles:string[])` method takes a list of roles. Roles can be any string. | ||
|
||
```ts | ||
from('users') | ||
.requires('users:read', 'users:write') | ||
.all() | ||
``` | ||
|
||
You can use an TypeScript enum to define the list of permissions and get extra type safety: | ||
|
||
```ts | ||
enum Permissions { | ||
usersRead = 'users:read', | ||
usersWrite = 'users:write', | ||
} | ||
|
||
from('users') | ||
.requires(Roles.usersRead, Roles.usersWrite) | ||
.all() | ||
``` | ||
|
||
## Role inheritance | ||
|
||
When you include a sub-query, the permissions add up. This means the user needs to have all the permissions of both the parent and sub-query to be able to execute. | ||
|
||
```ts | ||
const pets = from('pets') | ||
.requires('pets:read') | ||
.where({ owner_id: col('users.id') }) | ||
.all() | ||
|
||
from('users') | ||
.requires('users:read') | ||
.include({ pets }) | ||
.all() | ||
``` | ||
|
||
In this example, the user needs to have the `users:read` and `pets:read` permissions to execute the query. | ||
|
||
## Query context | ||
|
||
When you execute a query, you can pass a `context` object. This object is used to pass additional information to the query, such as the user's permissions. | ||
|
||
```ts | ||
const context = { permissions: ['users:read', 'pets:read'] }; | ||
queryEngine.execute(query, { context }); | ||
``` | ||
|
||
The query engine will traverse the query recursively and reject the query unless it meets all the ACL requirements. | ||
|
||
## Query registration | ||
|
||
When a query is registered, it is registered along with its permissions. This means a malicious client cannot modify the ACL requirements of a query. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
# Registered queries | ||
|
||
One of the core security goals of SynthQL is to be out-of-the-box secure. This means that by default the QueryEngine will not execute unknown queries. Queries need to be explicitly registered with the `QueryEngine` for them to be executed. | ||
|
||
```ts | ||
const findAllActiveUsers = () => from('users') | ||
.columns('id','name','email') | ||
.filter({active: true}) | ||
.all() | ||
|
||
const queryEngine = new QueryEngine({...}) | ||
|
||
// Register the query | ||
queryEngine.registerQuery(findAllActiveUsers()) | ||
|
||
// The QueryEngine will now only execute the registered queries | ||
queryEngine.execute(findAllActiveUsers(), { context }) | ||
``` | ||
|
||
## Why registered queries? | ||
|
||
Registered queries are a security feature that ensures that only known queries are executed. This prevents a potentially malicious actor from executing arbitrary queries on your database. | ||
|
||
When you build a traditional REST API, you implicitly "register queries" by defining the endpoints that are available. As SynthQL is more dynamic, we need an explicit mechanism to indicate that a query was authored in a safe context. | ||
|
||
## How to register queries | ||
|
||
Registering queries is simple. All you need to do is pass the query to the `QueryBuilder#registerQueries` or `QueryEngine#registerQuery` methods. | ||
|
||
As some queries take parameters, you will need to pass a placeholder value when you register the query. | ||
|
||
```ts | ||
import { param } from '@synthql/queries' | ||
import { from } from "../generated"; | ||
|
||
const findUserById = (id: number) => from('users') | ||
.columns('id','name','email') | ||
.filter({id}) | ||
.first() | ||
|
||
const queryEngine = new QueryEngine({...}) | ||
|
||
// Notice that we are passing a placeholder value of `0` for the | ||
// `id` parameter. We could have passed any value, it is essentially | ||
// telling the QueryEngine that the `id` is a parameter for the | ||
// query, and can be replaced with any value. | ||
queryEngine.registerQuery(findUserById(0)) | ||
|
||
// You can now invoke the query with any value | ||
queryEngine.execute(findUserById(anyUserId)) | ||
``` | ||
|
||
## Queries with conditional logic | ||
|
||
Some queries may have conditional logic. For example, you may want to alter the structure of the query based on the value of a parameter. | ||
|
||
```ts | ||
const findUsersByStatus = (status: 'active' | 'inactive') => { | ||
const query = from('users') | ||
.columns('id','name','email') | ||
.filter({status}) | ||
|
||
if (status === 'active') { | ||
// If the user is active, we want to return all users | ||
return query.all() | ||
} else { | ||
// If the user is not active, we want to return only | ||
// the first 100 users as there might be too many. | ||
return query.take(100) | ||
} | ||
} | ||
``` | ||
|
||
The problem with these types of queries is that they actually return two different query instances depending on the value of the `status` parameter. | ||
|
||
To register these types of queries correctly, you will need to register each variant of the query individually. | ||
|
||
```ts | ||
const queryEngine = new QueryEngine({...}) | ||
|
||
queryEngine.registerQueries([ | ||
findUsersByStatus('active'), | ||
findUsersByStatus('inactive') | ||
]) | ||
``` | ||
|
||
## The security of registered queries | ||
|
||
> Note: this is an advanced topic. You don't need to understand this section to use SynthQL. | ||
As a lot of the security of SynthQL depends on the registered queries, it is important to understand how they work. | ||
|
||
The high level idea is simple: | ||
|
||
1. Identifying queries: When a query is registered, we calculate a hash of the query. This hash is then used to identify the query. | ||
2. Checking queries: When a query is executed, the QueryEngine will check the hash of the query to ensure that it has been registered. | ||
3. Parameter substitution: When a query hash matches, the parameter values are substituted and the query is executed. | ||
|
||
### Identifying queries | ||
|
||
Every query is given a hash upon creation. The combination of `hash` and `name` is used to identify the query in the QueryEngine. | ||
|
||
When a query is registered, the QueryEngine calculates an ID based on the `hash` and `name` and stores it in memory. | ||
|
||
### Checking queries | ||
|
||
Whenever a query is executed, the QueryEngine will calculate the ID of the query and check if it has been registered. If it has, the query is executed. If it has not, the query is rejected. | ||
|
||
### Parameter substitution | ||
|
||
When a query is executed, the QueryEngine will substitute the parameter values into the query and execute it. | ||
|
||
Example: | ||
|
||
```ts | ||
const findUserById = (id: number) => from('users').filter({id}).first() | ||
|
||
// The QueryEngine will substitute the parameter values into the query and execute it. | ||
queryEngine.registerQuery(findUserById(0)) | ||
|
||
|
||
``` | ||
|
||
### What happens in case of a hash collision? | ||
|
||
If you try to register two different queries with the same hash, the QueryEngine will throw an error. | ||
|
||
If a malicious user tries to execute a query A with the hash of query B, it would be equivalent to simply trying to execute query B. | ||
|
||
### How is the hash calculated? | ||
|
||
The hash is calculated by `JSON.stringify`ing the query, and then hashing the result. We hash the stringified query to avoid sending possibly very large JSON strings over the wire. Special care is taken to ensure that the parts of the query that are parameterizable are not hardcoded in the hash. | ||
|
||
So for example, the following two queries will have the same hash: | ||
|
||
```ts | ||
const queryA = from('users').filter({active: true}).all() | ||
const queryB = from('users').filter({active: false}).all() | ||
``` | ||
|
||
But these two will not: | ||
|
||
```ts | ||
const queryC = from('users').columns('id','name','email').filter({active: true}).all() | ||
const queryD = from('users').columns('email').filter({active: true}).all() | ||
``` | ||
|
||
The Query hash is calculated by the [`hashQuery`](https://github.com/synthql/SynthQL/blob/master/packages/queries/src/util/hashQuery.ts#L9) function. | ||
|