Skip to content

Commit

Permalink
feat: ✨ filter results in app and not within db query
Browse files Browse the repository at this point in the history
  • Loading branch information
dennemark committed Jul 30, 2024
1 parent 30a3fd4 commit 3af823f
Show file tree
Hide file tree
Showing 19 changed files with 721 additions and 523 deletions.
26 changes: 26 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"editor.tabSize": 2,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.sortImports": "explicit"
},

"[jsonc]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[javascriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"typescript.preferences.importModuleSpecifierEnding": "auto"
}
188 changes: 67 additions & 121 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,154 +1,100 @@
# Prisma Extension CASL


[Prisma client extension](https://www.prisma.io/docs/orm/prisma-client/client-extensions) that utilizes [CASL](https://casl.js.org/) to enforce authorization logic on most queries.
[Prisma client extension](https://www.prisma.io/docs/orm/prisma-client/client-extensions) that utilizes [CASL](https://casl.js.org/) to enforce authorization logic on most queries.

> [!CAUTION]
>
> WIP - some abstractions might change in the future and lead to different interpretation of CASL rules.
>
> Please be very careful using this library in production! Test your endpoints on your own and raise an issue if some case is not supported by this library!
Supports mainly/only CRUD actions `create`, `read`, `update` and `delete`, which allows us to generate and transform `include`, `select` and `where` queries to enforce nested filtering.
Mutating queries will throw errors in a similar format as CASL. `It's not allowed to "update" "email" on "User"`.
- Supports only CRUD actions `create`, `read`, `update` and `delete`.
- Rule conditions are applied via `accessibleBy` and if `include` or `select` are used, this will even be nested.
- Mutating queries will throw errors in a similar format as CASL. `It's not allowed to "update" "email" on "User"`.
- The query result with nested entries is filtered by `read` ability.
- On nested `connect`, `disconnect`, `upsert` or `connectOrCreate` mutation queries the client assumes an `update` action for insertion or connection.

### Examples

Now how does it work?

```ts
const { can, build } = abilityBuilder()
can('read', 'Post', {
thread: {
creatorId: 0
}
})
can('read', 'Thread', 'id')
const caslClient = prismaClient.$extends(
useCaslAbilities(build)
)
const result = await caslClient.post.findMany({
include: {
thread: true
}
})
/**
* creates a query under the hood with assistance of @casl/prisma
*
* and even adds a proper select query to thread
*
*{
* where: {
* AND: [{
* OR: [{
* thread: {
* creatorId: 0
* }
* }]
* }]
* }
* include: {
* thread: {
* select: {
* id: true
* }
* }
* }
* }
*/
const { can, build } = abilityBuilder();
can("read", "Post", {
thread: {
creatorId: 0,
},
});
can("read", "Thread", "id");
const caslClient = prismaClient.$extends(useCaslAbilities(build));
const result = await caslClient.post.findMany({
include: {
thread: true,
},
});
/**
* creates a query under the hood with assistance of @casl/prisma
*
*{
* where: {
* AND: [{
* OR: [{
* thread: {
* creatorId: 0
* }
* }]
* }]
* }
* include: {
* thread: true
* }
*
* and result will be filtered and should look like
* { id: 0, threadId: 0, thread: { id: 0 } }
*/
```

Mutations will only run, if abilities allow it.

```ts
const { can, build } = abilityBuilder()
can('update', 'Post')
cannot('update', 'Post', 'text')
const caslClient = prismaClient.$extends(
useCaslAbilities(build)
)
const result = await caslClient.post.update({ data: { text: '-' }, where: { id: 0 }})
/**
* will throw an error
* because update on text is not allowed
*/
const { can, build } = abilityBuilder();
can("update", "Post");
cannot("update", "Post", "text");
const caslClient = prismaClient.$extends(useCaslAbilities(build));
const result = await caslClient.post.update({
data: { text: "-" },
where: { id: 0 },
});
/**
* will throw an error
* because update on text is not allowed
*/
```

Check out tests for some other examples.


### Limitations and Constraints

#### Nested mutations use `update` connection

On nested `connect`, `disconnect`, `upsert` or `connectOrCreate` mutation queries the client assumes an `update` action for insertion or connection.


#### CRUD actions

A limitation is the necessary use of `create`, `read`, `update` and `delete` as actions for nested queries. Since this allows us to deal with nested creations or updates. However there is an option to specify a custom `caslAction` for the highest query. It has no typing and is not tested yet.

```ts
client.user.findUnique({ where: { id: 0 }, caslAction: 'customAction' })
```

#### Avoid columns with prisma naming

When using prisma probably no one will use columns named `data`, `create`, `update` or `where`. However, if this should be the case, then this library most probably won't work.
When using prisma probably no one will use columns named `data`, `create`, `update`, `select` or `where`. However, if this should be the case, then this library most probably won't work.

#### Limit fields via conditions
The main use case is allowing more fields for some users:
```ts
can('read', 'User', 'email')
can('read', 'User', ['email', 'id'], {
id: 0
})
client.user.findMany()
// will return all users with email
// however
client.user.findMany({ id: 0 })
// will show email and id!
```
If fields should only be permitted on certain conditions, they will only be accessible, if these conditions apply. The reason is, that we do not know if it contradicts another rule.
See this example:
#### Relational conditions need read rights on relation and include statment

To filter fields, the data of the conditions needs to be available, since filtering does not happen on the database but in our app.

```ts
can('read', 'User')
can('read', 'User', 'email', {
id: 0
})
can('read', 'User', 'id', {
id: 1
})
client.user.findMany()
// will return all users and more than the email property
// since we cannot check if id is 0 or 1

// however if our query matches the condition its rule will be used
client.user.findMany({ id: 0 })
// will restrict access to email prop
can("read", "User", "email", {
post: {
ownerId: 0,
},
});
can("read", "Post", ["id"]);
client.user.findMany(); // []

client.user.findMany({ include: { post: true } }); // [{ email: "-", post: { id: 0 } }]
```
This makes DX a bit inconvenient, since we have to use our condition within our query.


#### Nested fields and wildcards are not supported

`can('read', 'User', ['nested.field', 'field.*'])` won't work. Although a wildcard could be useful in the future.
#### Nested fields and wildcards are not supported / tested

#### Conditionally filter fields with cannot
Currently the following case is not supported. It is still possible to read `email`. Waithing for reply [here](https://github.com/stalniy/casl/discussions/948).

```ts
can('read', 'User', {
id: 1
})
cannot('read', 'User', 'email', {
id: 0
})
```
In real world application, rather consider this:
```ts
cannot('read', 'User', 'email')
can('read', 'User', {
id: userId
})
```
`can('read', 'User', ['nested.field', 'field.*'])` probably won't work.
18 changes: 9 additions & 9 deletions src/applyAccessibleQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
* @param accessibleQuery casl accessibleBy query result
* @returns enriched query
*/
export function applyAccessibleQuery(query: any, accessibleQuery: any){
export function applyAccessibleQuery(query: any, accessibleQuery: any) {
return {
...query,
AND: [
...(query.AND ?? []),
accessibleQuery
]
}
}
...query,
AND: [
...(query.AND ?? []),
accessibleQuery
]

}
}
25 changes: 13 additions & 12 deletions src/applyCaslToQuery.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { AbilityTuple, PureAbility } from '@casl/ability'
import { PrismaQuery } from '@casl/prisma'
import { Prisma } from '@prisma/client'
import { accessibleBy } from "@casl/prisma"
import { caslOperationDict, PrismaCaslOperation } from "./helpers"
import { applyDataQuery } from "./applyDataQuery"
import { applyWhereQuery } from "./applyWhereQuery"
import { applyIncludeSelectQuery } from "./applyIncludeSelectQuery"
import { AbilityTuple, PureAbility } from '@casl/ability'
import { PrismaQuery } from '@casl/prisma'
import { applyWhereQuery } from "./applyWhereQuery"
import { caslOperationDict, PrismaCaslOperation } from "./helpers"

/**
* Applies CASL authorization logic to prisma query
Expand All @@ -17,14 +16,11 @@ import { PrismaQuery } from '@casl/prisma'
* @returns Enriched query with casl authorization
*/
export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, model: Prisma.ModelName) {
const operationAbility = caslOperationDict[operation as PrismaCaslOperation]
if (args.caslAction) {
operationAbility.action = args.caslAction
}
const operationAbility = caslOperationDict[operation]

accessibleBy(abilities, operationAbility.action)[model]
if(operationAbility.dataQuery && args.data) {
// accessibleBy(abilities, operationAbility.action)[model]

if (operationAbility.dataQuery && args.data) {
args.data = applyDataQuery(abilities, args.data, operationAbility.action, model)
}

Expand All @@ -33,8 +29,13 @@ export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abil
args = applyWhereQuery(abilities, args, operationAbility.action, model)
}


if (operationAbility.includeSelectQuery) {
args = applyIncludeSelectQuery(abilities, args, model)
} else {
delete args.include
delete args.select
}
console.dir(args, { depth: null })
return args
}
Loading

0 comments on commit 3af823f

Please sign in to comment.