Skip to content

Commit

Permalink
fix: 🐛 get nested values to check rules
Browse files Browse the repository at this point in the history
  • Loading branch information
dennemark committed Sep 5, 2024
1 parent 9342b3b commit 9096f6a
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 23 deletions.
95 changes: 75 additions & 20 deletions src/applyRuleRelationsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createPrismaAbility, PrismaQuery } from '@casl/prisma';
import { Prisma } from '@prisma/client';
import { convertCreationTreeToSelect, CreationTree } from './convertCreationTreeToSelect';
import { getRuleRelationsQuery } from './getRuleRelationsQuery';
import { relationFieldsByModel } from './helpers';



Expand Down Expand Up @@ -36,7 +37,7 @@ function mergeArgsAndRelationQuery(args: any, relationQuery: any) {

} 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]
const child = relationQuery[key].select ? mergeArgsAndRelationQuery(args[method][key], relationQuery[key].select) : { args: args[method][key], mask: true }
args[method][key] = child.args
mask[key] = child.mask
} else if (args[method][key] === true) {
Expand Down Expand Up @@ -68,15 +69,14 @@ function mergeArgsAndRelationQuery(args: any, relationQuery: any) {
}
})


if (found === false) {
Object.entries(relationQuery).forEach(([k, v]: [string, any]) => {
if (v?.select) {
args.include = {
...(args.include ?? {}),
[k]: v
}
mask[k] = v
mask[k] = removeNestedIncludeSelect(v.select)
}
})
}
Expand All @@ -86,8 +86,24 @@ function mergeArgsAndRelationQuery(args: any, relationQuery: any) {
mask
}
}


/**
* recursively removes all selects and includes from a select query to get a clean mask
* { posts: { select: { thread: { select: { id: true }}}}}
* { posts: { thread: { id: true }}}
* @param args select query
* @returns mask
*/
function removeNestedIncludeSelect(args: any) {
return typeof args === 'object' ? Object.fromEntries((Object.entries(args) as [string, any]).map(([k, v]): [string, any] => {
if (v?.select) {
return [k, removeNestedIncludeSelect(v.select)]
} else if (v?.include) {
return [k, removeNestedIncludeSelect(v.include)]
} else {
return [k, v]
}
})) : args
}


/**
Expand All @@ -106,33 +122,72 @@ function mergeArgsAndRelationQuery(args: any, relationQuery: any) {
*/
export function applyRuleRelationsQuery(args: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, action: string, model: Prisma.ModelName, creationTree?: CreationTree) {


// rulesToAST won't return conditions
// if a rule is inverted and if a can rule exists without condition
// we therefore create fake ability here
// to get our rule relations query
const ability = createPrismaAbility(abilities.rules.filter((rule) => rule.conditions).map((rule) => {
return {
...rule,
inverted: false
}
}))
const ast = rulesToAST(ability, action, model)
const creationSelectQuery = creationTree ? convertCreationTreeToSelect(abilities, creationTree) ?? {} : {}

const queryRelations = getRuleRelationsQuery(model, ast, creationSelectQuery === true ? {} : creationSelectQuery)

const queryRelations = getNestedQueryRelations(args, abilities, action, model, creationSelectQuery === true ? {} : creationSelectQuery)

if (!args.select && !args.include) {
args.include = {}
}

// merge rule query relations with current arguments and creates new args and a mask that will be used to remove values that are only necessary to evaluate rules
const result = mergeArgsAndRelationQuery(args, queryRelations)

if (result.args.include && Object.keys(result.args.include!).length === 0) {
if ('include' in result.args && Object.keys(result.args.include!).length === 0) {
delete result.args.include
}

return { ...result, creationTree }
}


/**
*
* gets all query relations that are necessary to evaluate rules later on
*
* @param args query
* @param abilities Casl prisma abilities
* @param action Casl action - preferably create/read/update/delete
* @param model prisma model
* @param creationSelectQuery
* @returns `{ args: mergedQuery, mask: description of fields that should be removed from result }`
*/
function getNestedQueryRelations(args: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, action: string, model: Prisma.ModelName, creationSelectQuery: any = {}) {
// rulesToAST won't return conditions
// if a rule is inverted and if a can rule exists without condition
// we therefore create fake ability here
// to get our rule relations query
const ability = createPrismaAbility(abilities.rules.filter((rule) => rule.conditions).map((rule) => {
return {
...rule,
inverted: false
}
}))
const ast = rulesToAST(ability, action, model)

const queryRelations = getRuleRelationsQuery(model, ast, creationSelectQuery === true ? {} : creationSelectQuery)
;['include', 'select'].map((method) => {
if (args && args[method]) {
for (const relation in args[method]) {
if (model in relationFieldsByModel && relation in relationFieldsByModel[model]) {
const relationField = relationFieldsByModel[model][relation]

if (relationField) {
const nestedQueryRelations = {
...getNestedQueryRelations(args[method][relation], abilities, 'read', relationField.type as Prisma.ModelName),
...(queryRelations[relation]?.select ?? {})
}
if (nestedQueryRelations && Object.keys(nestedQueryRelations).length > 0) {
queryRelations[relation] = {
...(queryRelations[relation] ?? {}),
select: nestedQueryRelations
}
}
}
}
}
}
})

return queryRelations
}
2 changes: 1 addition & 1 deletion src/getRuleRelationsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function getRuleRelationsQuery(model: string, ast: any, dataRelationQuery
const relation = relationFieldsByModel[model]
if (childAst.field) {
if (childAst.field in relation) {
const dataInclude = childAst.field in obj ? obj[childAst.field] : {}
const dataInclude = obj[childAst.field] !== undefined ? obj[childAst.field] : {}
obj[childAst.field] = {
select: getRuleRelationsQuery(relation[childAst.field].type, childAst.value, dataInclude === true ? {} : dataInclude.select)
}
Expand Down
16 changes: 16 additions & 0 deletions test/applyCaslToQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,15 @@ describe('apply casl to query', () => {
author: {
select: {
email: true,
id: true,
posts: {
include: {
thread: {
select: {
creatorId: true
}
}
},
where: {
AND: [{
OR: [{
Expand Down Expand Up @@ -97,6 +105,14 @@ describe('apply casl to query', () => {
}
})
expect(result.mask).toEqual({
author: {
id: true,
posts: {
thread: {
creatorId: true
}
}
},
thread: true
})
})
Expand Down
59 changes: 59 additions & 0 deletions test/applyRuleRelationsQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

import { applyIncludeSelectQuery } from '../src/applyIncludeSelectQuery'
import { applyRuleRelationsQuery } from '../src/applyRuleRelationsQuery'
import { abilityBuilder } from './abilities'

Expand Down Expand Up @@ -238,5 +239,63 @@ describe('apply rule relations query', () => {
})
expect(mask).toEqual({ posts: true })
})
it('applies select method on nested permission', () => {
const { can, cannot, build } = abilityBuilder()
can('read', 'Post', 'id', {
author: {
is: {
threads: {
some: {
id: 0
}
}
}
}
})

const includeArgs = applyIncludeSelectQuery(build(), { include: { posts: true } }, 'User')

const { args, mask } = applyRuleRelationsQuery(includeArgs, build(), 'read', 'User')
expect(mask).toEqual({
posts: {
author: {
threads: {
id: true
}
}
}
})
expect(args?.include).toEqual({
posts: {
where: {
AND: [{
OR: [{
author: {
is: {
threads: {
some: {
id: 0
}
}
}
},
}]
}]

},
include: {
author: {
select: {
threads: {
select: {
id: true
}
}
}
}
}

}
})
})
})
4 changes: 2 additions & 2 deletions test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ describe('prisma extension casl', () => {



it('does not include nested fields if query does not include properties to check for rules', async () => {
it('does include nested fields if query does not include properties to check for rules', async () => {
function builderFactory() {
const builder = abilityBuilder()
const { can, cannot } = builder
Expand Down Expand Up @@ -491,7 +491,7 @@ describe('prisma extension casl', () => {
}
},
})
expect(result).toEqual({ author: { email: '1' } })
expect(result).toEqual({ author: { email: '1', posts: [{ id: 1 }] } })
})

it('includes nested fields if query does not include properties to check for rules', async () => {
Expand Down

0 comments on commit 9096f6a

Please sign in to comment.