Skip to content

Commit

Permalink
feat: ✨ filter permissions on query result
Browse files Browse the repository at this point in the history
enriches query by rule conditions and removes their fields afterwards
  • Loading branch information
dennemark committed Jul 31, 2024
1 parent d3a3f9f commit 54b0d8b
Show file tree
Hide file tree
Showing 9 changed files with 472 additions and 120 deletions.
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
> 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 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.
- The permissions on fields of the query result are filtered by `read` ability.
- Rule conditions are automatically applied via `accessibleBy` and if `include` or `select` are used, this will even be applied to the nested relations.
- 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
Expand Down Expand Up @@ -79,22 +79,51 @@ Check out tests for some other examples.

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.

#### Relational conditions need read rights on relation and include statment
#### Filtering properties happens in prisma extension and not on database

To filter fields, the data of the conditions needs to be available, since filtering does not happen on the database but in our app.
A prisma query should result in the queried data with only permitted fields.
Since conditional filtering of fields cannot be done within a database query by prisma, the extension does this after querying the data. However, to get permitted fields per queried data, all the fields mentioned in rule conditions need to be available to, within the extension. Therefore the extension gathers all the necessary fields even if no `read` rights exist for them. The query itself and the data might be large and performance slower than a simple query, but now we can apply the CASL abilities to remove all restricted fields. Within this process we also remove all the additional queried data, so that the final result represents permittedd data of the actual prisma query.

```ts
can("read", "User", "email", {
post: {
ownerId: 0,
posts: {
some: {
authorId: 0,
}
},
});
can("read", "Post", ["id"]);
client.user.findMany(); // []
cannot('read', 'Post') // !!! we cannot read post

client.user.findMany({ include: { post: true } }); // [{ email: "-", post: { id: 0 } }]
const result = client.user.findMany();

console.log(result) // [{ email: "-" }]


/**
* internally this query is used:
*
* {
* where: {
* AND: [{
* OR: [{
* posts: { some: { authorId: 0 } }
* }]
* }]
* },
* include: { posts: { select: { authorId: true } } }
* }
* /
```
Here are some performance metrics for the above query for the small test sqlite db:
- plain prisma query: **0.533909**
- casl prisma query: **3.269526**
- create abilities: **0.446727**
- enrich query with casl: **1.080605**
- prisma query: **1.175109**
- filtering query results: **0.567084**
#### Nested fields and wildcards are not supported / tested
`can('read', 'User', ['nested.field', 'field.*'])` probably won't work.
13 changes: 9 additions & 4 deletions src/applyCaslToQuery.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AbilityTuple, PureAbility } from '@casl/ability'
import { PrismaQuery } from '@casl/prisma'
import { accessibleBy, PrismaQuery } from '@casl/prisma'
import { Prisma } from '@prisma/client'
import { applyDataQuery } from "./applyDataQuery"
import { applyIncludeSelectQuery } from "./applyIncludeSelectQuery"
import { applyRuleRelationsQuery } from './applyRuleRelationsQuery'
import { applyWhereQuery } from "./applyWhereQuery"
import { caslOperationDict, PrismaCaslOperation } from "./helpers"

Expand All @@ -18,7 +19,7 @@ import { caslOperationDict, PrismaCaslOperation } from "./helpers"
export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, model: Prisma.ModelName) {
const operationAbility = caslOperationDict[operation]

// accessibleBy(abilities, operationAbility.action)[model]
accessibleBy(abilities, operationAbility.action)[model]

if (operationAbility.dataQuery && args.data) {
args.data = applyDataQuery(abilities, args.data, operationAbility.action, model)
Expand All @@ -36,6 +37,10 @@ export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abil
delete args.include
delete args.select
}
console.dir(args, { depth: null })
return args

const result = operationAbility.includeSelectQuery
? applyRuleRelationsQuery(args, abilities, operationAbility.action, model)
: { args, mask: undefined }

return result
}
1 change: 1 addition & 0 deletions src/applyIncludeSelectQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const applyIncludeSelectQuery = (
}
}
})

return args

}
148 changes: 148 additions & 0 deletions src/applyRuleRelationsQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { AbilityTuple, PureAbility, Subject, ɵvalue } from '@casl/ability';
import { rulesToAST } from '@casl/ability/extra';
import { PrismaQuery } from '@casl/prisma';
import { Prisma } from '@prisma/client';
import { relationFieldsByModel } from './helpers';

function getRuleRelationsQuery(model: string, ast: any) {
const obj: Record<string, any> = {}
if (ast) {
if (typeof ast.value === 'object') {
if (Array.isArray(ast.value)) {
ast.value.map((childAst: any) => {
const relation = relationFieldsByModel[model]
if (childAst.field) {
if (childAst.field in relation) {
obj[childAst.field] = {
select: getRuleRelationsQuery(relation[childAst.field].type, childAst.value)
}
} else {
obj[childAst.field] = true
}
}
})
} else {
const relation = relationFieldsByModel[model]
if (ast.field) {
if (ast.field in relation) {
obj[ast.field] = {
select: getRuleRelationsQuery(relation[ast.field].type, ast.value)
}
} else {
obj[ast.field] = true
}
}
}
} else {
obj[ast.field] = true
}
}
return obj
}

/**
* takes args query and rule relation query
* and combines them, while also keeping a mask
* of the difference, to later on remove all rule relation
* results from query result
*
* @param args query with { include, select }
* @param relationQuery query result of getRuleRelationsQuery
* @returns `{ args: mergedQuery, mask: differenc of both queries }`
*/
function mergeArgsAndRelationQuery(args: any, relationQuery: any) {
const mask: Record<string, any> = {}
let found = false
;['include', 'select'].map((method) => {
if (args[method]) {
found = true
for (const key in relationQuery) {
// relations on relationQuery have a select
if (!(key in args[method])) {
if (relationQuery[key].select || method === 'select') {
args[method][key] = relationQuery[key]
mask[key] = true
}

} else if (args[method][key] && typeof args[method][key] === 'object') {
// if current field is an object, we recurse merging
const child = relationQuery[key].select ? mergeArgsAndRelationQuery(args[method][key], relationQuery[key].select) : args[method][key]
args[method][key] = child.args
mask[key] = child.mask
} else if (args[method][key] === true) {
// if field is true it expects all fields
// but we need to get nested relations, therefore
// we convert relation select to include with only relation fields
// (relation fields have a select prop)
if (relationQuery[key].select) {
for (const field in relationQuery[key].select) {
if (relationQuery[key].select[field]?.select) {
args[method][key] = {
include: {
...(args[method][key]?.include ?? {}),
[field]: relationQuery[key].select[field]
}
}
mask[key] = {
...(mask?.[key] ?? {}),
[field]: true
}
}

}

}

}
}
}
})


if (found === false) {
Object.entries(relationQuery).forEach(([k, v]: [string, any]) => {
if (v?.select) {
args.include = {
...(args.include ?? {}),
[k]: v
}
mask[k] = v
}
})
}

return {
args,
mask
}
}




/**
* filterQueryResults needs to work with all data that is related to rules
* a query might not load this data, therefore we add the rule condition fields
* to the query
*
* we also generate a mask that can be used by filterQueryResults to
* remove unused fields
*
* @param args query
* @param abilities Casl prisma abilities
* @param action Casl action - preferably create/read/update/delete
* @param model prisma model
* @returns `{ args: mergedQuery, mask: description of fields that should be removed from result }`
*/
export function applyRuleRelationsQuery(args: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, action: string, model: Prisma.ModelName) {

const ast = rulesToAST(abilities, action, model)
const queryRelations = getRuleRelationsQuery(model, ast)

return mergeArgsAndRelationQuery(args, queryRelations)
}




// Maske aussortieren
21 changes: 14 additions & 7 deletions src/filterQueryResults.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { AbilityTuple, PureAbility } from "@casl/ability";
import { PrismaQuery } from "@casl/prisma";
import { accessibleBy, PrismaQuery } from "@casl/prisma";
import { Prisma } from "@prisma/client";
import { getPermittedFields, relationFieldsByModel } from "./helpers";

export function filterQueryResults(result: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, model: string) {
export function filterQueryResults(result: any, mask: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, model: string) {
const prismaModel = model in relationFieldsByModel ? model as Prisma.ModelName : undefined
if (!prismaModel) {
throw new Error(`Model ${model} does not exist on Prisma Client`)
Expand All @@ -12,22 +12,29 @@ export function filterQueryResults(result: any, abilities: PureAbility<AbilityTu
const filterPermittedFields = (entry: any) => {
if (!entry) { return null }
const permittedFields = getPermittedFields(abilities, 'read', model, entry)
console.log(model, entry, permittedFields)
let hasKeys = false
Object.keys(entry).forEach((field) => {
const relationField = relationFieldsByModel[model][field]
if (!permittedFields.includes(field) && !relationField) {

if ((!permittedFields.includes(field) && !relationField) || mask?.[field] === true) {
delete entry[field]
} else if (relationField) {
hasKeys = true
entry[field] = filterQueryResults(entry[field], abilities, relationField.type)
entry[field] = filterQueryResults(entry[field], mask?.[field], abilities, relationField.type)
if (entry[field] === null) {
delete entry[field]
}
} else {
hasKeys = true
}
})

return hasKeys ? entry : null
}
// console.log(model, JSON.stringify(Array.isArray(result) ? result.map((entry)=>filterPermittedFields(entry)) : filterPermittedFields(result)))
return Array.isArray(result) ? result.map((entry) => filterPermittedFields(entry)).filter((x) => x) : filterPermittedFields(result)
if (Array.isArray(result)) {
const arr = result.map((entry) => filterPermittedFields(entry)).filter((x) => x)
return arr.length > 0 ? arr : null
} else {
return filterPermittedFields(result)
}
}
Loading

0 comments on commit 54b0d8b

Please sign in to comment.