diff --git a/README.md b/README.md index 06589cc..b8a81d3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/src/applyCaslToQuery.ts b/src/applyCaslToQuery.ts index ae9fc34..64eb810 100644 --- a/src/applyCaslToQuery.ts +++ b/src/applyCaslToQuery.ts @@ -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" @@ -18,7 +19,7 @@ import { caslOperationDict, PrismaCaslOperation } from "./helpers" export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abilities: PureAbility, 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) @@ -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 } \ No newline at end of file diff --git a/src/applyIncludeSelectQuery.ts b/src/applyIncludeSelectQuery.ts index 495f070..0e6e43b 100644 --- a/src/applyIncludeSelectQuery.ts +++ b/src/applyIncludeSelectQuery.ts @@ -41,6 +41,7 @@ export const applyIncludeSelectQuery = ( } } }) + return args } \ No newline at end of file diff --git a/src/applyRuleRelationsQuery.ts b/src/applyRuleRelationsQuery.ts new file mode 100644 index 0000000..cd87484 --- /dev/null +++ b/src/applyRuleRelationsQuery.ts @@ -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 = {} + 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 = {} + 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, action: string, model: Prisma.ModelName) { + + const ast = rulesToAST(abilities, action, model) + const queryRelations = getRuleRelationsQuery(model, ast) + + return mergeArgsAndRelationQuery(args, queryRelations) +} + + + + +// Maske aussortieren \ No newline at end of file diff --git a/src/filterQueryResults.ts b/src/filterQueryResults.ts index a555e72..ecfaa40 100644 --- a/src/filterQueryResults.ts +++ b/src/filterQueryResults.ts @@ -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, model: string) { +export function filterQueryResults(result: any, mask: any, abilities: PureAbility, 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`) @@ -12,15 +12,18 @@ export function filterQueryResults(result: any, abilities: PureAbility { 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 } @@ -28,6 +31,10 @@ export function filterQueryResults(result: any, abilities: PureAbilityfilterPermittedFields(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) + } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index effe966..731f8ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { PrismaQuery } from '@casl/prisma' import { Prisma } from '@prisma/client' import { applyCaslToQuery } from './applyCaslToQuery' import { filterQueryResults } from './filterQueryResults' -import { getFluentModel } from './helpers' +import { caslOperationDict, getFluentModel } from './helpers' export { applyCaslToQuery } @@ -25,64 +25,36 @@ export const useCaslAbilities = (getAbilities: () => PureAbility filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - createMany({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - createManyAndReturn({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - upsert({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - findFirst({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - findFirstOrThrow({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - findMany({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - findUnique({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - findUniqueOrThrow({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - aggregate({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - count({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - groupBy({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - update({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - updateMany({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - delete({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - deleteMany({ args, query, model, operation, ...rest }) { - return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) - }, - // async $allOperations({ args, query, model, operation }: { args: any, query: any, model: any, operation: any }) { + async $allOperations({ args, query, model, operation, ...rest }: { args: any, query: any, model: any, operation: any }) { + // performance.clearMeasures() + // performance.clearMarks() + if (!(operation in caslOperationDict)) { + return query(args) + } + // performance.mark('start') + const abilities = getAbilities() + // performance.mark('abilities') + const caslQuery = applyCaslToQuery(operation, args, abilities, model) - // if (!(operation in caslOperationDict)) { - // return query(args) - // } + // performance.mark('finishCaslQuery') + return query(caslQuery.args).then((result: any) => { + // performance.mark('finishQuery') - // args = applyCaslToQuery(operation, args, getAbilities(), model) - - // return query(args) - // }, + const res = filterQueryResults(result, caslQuery.mask, abilities, getFluentModel(model, rest)) + // performance.mark('finishFiltering') + // console.log( + // [performance.measure('overall', 'start', 'finishFiltering'), + // performance.measure('create abilities', 'start', 'abilities'), + // performance.measure('create casl query', 'abilities', 'finishCaslQuery'), + // performance.measure('finish query', 'finishCaslQuery', 'finishQuery'), + // performance.measure('filtering results', 'finishQuery', 'finishFiltering') + // ].map((measure) => { + // return `${measure.name}: ${measure.duration}` + // }) + // ) + return res + }) + }, }, } }) diff --git a/test/applyCaslToQuery.test.ts b/test/applyCaslToQuery.test.ts index d50b51c..25b468a 100644 --- a/test/applyCaslToQuery.test.ts +++ b/test/applyCaslToQuery.test.ts @@ -18,7 +18,7 @@ describe('apply casl to query', () => { posts: true } }, abilities, 'User') - expect(result).toEqual({ + expect(result.args).toEqual({ include: { posts: true }, @@ -26,14 +26,16 @@ describe('apply casl to query', () => { id: 1 } }) - + expect(result.mask).toEqual({}) }) it('applies nested select and where', async () => { const { can, build } = abilityBuilder() can('read', 'Post', ['id'], { thread: { - creatorId: 0 + is: { + creatorId: 0 + } } }) can('read', 'User', ['email', 'id'], { @@ -54,7 +56,7 @@ describe('apply casl to query', () => { } }, }, abilities, 'Post') - expect(result).toEqual({ + expect(result.args).toEqual({ select: { author: { select: { @@ -64,20 +66,29 @@ describe('apply casl to query', () => { AND: [{ OR: [{ thread: { - creatorId: 0 + is: { + creatorId: 0 + } } }] }] } } } + }, + thread: { + select: { + creatorId: true + } } }, where: { AND: [{ OR: [{ thread: { - creatorId: 0 + is: { + creatorId: 0 + } } } ] @@ -85,7 +96,9 @@ describe('apply casl to query', () => { id: 1, } }) - + expect(result.mask).toEqual({ + thread: true + }) }) it('ignores conditional rule', () => { const { can, build } = abilityBuilder() @@ -95,28 +108,13 @@ describe('apply casl to query', () => { }) const abilities = build() const result = applyCaslToQuery('findUnique', {}, abilities, 'User') - expect(result).toEqual({}) + expect(result.args).toEqual({}) + expect(result.mask).toEqual({ + + }) }) - // it('applies filter props and ignores weaker can rule', ()=>{ - // const { can, build } = abilityBuilder() - // can('read', 'User', { - // id: 0 - // }) - // can('read', 'User', ['email', 'id']) - // const abilities = build() - // const result = applyCaslToQuery('findUnique', {}, abilities, 'User') - // expect(result).toEqual({}) - // }) - // it('allows to see more props on a condition', ()=>{ - // const { can, build } = abilityBuilder() - // can('read', 'User', 'email') - // can('read', 'User', ['email', 'id'], {id:0}) - // const abilities = build() - // const result = applyCaslToQuery('findUnique', { where: { id: 0 } }, abilities, 'User') - // expect(result).toEqual({ where: { id: 0 }}) - // }) - it('applies where condition condition', () => { + it('applies where condition', () => { const { can, build } = abilityBuilder() can('read', 'User', 'email', { id: 0 }) @@ -127,7 +125,10 @@ describe('apply casl to query', () => { } }, abilities, 'User') - expect(result).toEqual({ where: { AND: [{ OR: [{ id: 0 }] }] }, select: { email: true } }) + expect(result.args).toEqual({ where: { AND: [{ OR: [{ id: 0 }] }] }, select: { id: true, email: true } }) + expect(result.mask).toEqual({ + id: true + }) }) Object.entries(caslOperationDict).map(([operation, settings]) => { it(`${operation} applies ${settings.dataQuery ? 'data' : 'no data'} ${settings.whereQuery ? 'where' : 'no where'} and ${settings.includeSelectQuery ? 'include/select' : 'no include/select'} query`, () => { @@ -147,21 +148,23 @@ describe('apply casl to query', () => { if (settings.dataQuery) { - expect(result.data).toEqual({ id: 0 }) + expect(result.args.data).toEqual({ id: 0 }) } else { - expect(result.data).toBeUndefined() + expect(result.args.data).toBeUndefined() } if (settings.whereQuery) { - expect(result.where).toEqual({ AND: [{ OR: [{ id: 0 }] }] }) + expect(result.args.where).toEqual({ AND: [{ OR: [{ id: 0 }] }] }) } else { - expect(result.where).toBeUndefined() + expect(result.args.where).toBeUndefined() } if (settings.includeSelectQuery) { - expect(result.include).toEqual({ posts: true }) - expect(result.select).toBeUndefined() + expect(result.args.include).toEqual({ posts: true }) + expect(result.args.select).toBeUndefined() + expect(result.mask).toEqual({}) } else { - expect(result.include).toBeUndefined() - expect(result.select).toBeUndefined() + expect(result.args.include).toBeUndefined() + expect(result.args.select).toBeUndefined() + expect(result.mask).toBeUndefined() } }) }) diff --git a/test/applyRuleRelationsQuery.test.ts b/test/applyRuleRelationsQuery.test.ts new file mode 100644 index 0000000..9e97a13 --- /dev/null +++ b/test/applyRuleRelationsQuery.test.ts @@ -0,0 +1,172 @@ + +import { applyRuleRelationsQuery } from '../src/applyRuleRelationsQuery' +import { abilityBuilder } from './abilities' + + + +describe('apply rule relations query', () => { + it('adds missing select queries for list relation', () => { + const { can, build } = abilityBuilder() + can('read', 'User', { + posts: { + some: { + text: '0', + id: 0, + author: { + is: { + id: 0 + } + }, + threadId: 0 + } + }, + email: { + contains: '1', + endsWith: '1' + } + }) + const { args, mask } = applyRuleRelationsQuery({ + select: { + posts: { + select: { + text: true + } + } + } + }, build(), 'read', 'User') + expect(args).toEqual({ + select: { + posts: { + select: { + text: true, + id: true, + author: { + select: { + id: true + } + }, + threadId: true + } + }, + email: true + }, + }) + expect(mask).toEqual({ + posts: { + id: true, + author: true, + threadId: true + }, + email: true + }) + }) + it('adds missing include queries for list relation', () => { + const { can, build } = abilityBuilder() + can('read', 'User', { + posts: { + some: { + text: '0', + id: 0, + author: { + is: { + id: 0 + } + }, + threadId: 0 + } + }, + email: { + contains: '1', + endsWith: '1' + } + }) + const { args, mask } = applyRuleRelationsQuery({ + include: { + posts: true + } + }, build(), 'read', 'User') + expect(args).toEqual({ + include: { + posts: { + include: { + author: { + select: { + id: true + } + }, + } + } + }, + }) + expect(mask).toEqual({ + posts: { + author: true, + }, + }) + }) + it('adds missing select queries for item relation', () => { + const { can, build } = abilityBuilder() + can('read', 'Post', { + thread: { + is: { + id: 0 + } + } + }) + const { args, mask } = applyRuleRelationsQuery({ + select: { + author: { + id: true + } + } + }, build(), 'read', 'Post') + expect(args).toEqual({ + select: { + thread: { + select: { + id: true + } + }, + author: { + id: true + } + } + }) + expect(mask).toEqual({ + thread: true, + }) + }) + + it('adds missing select queries if select query exists for item relation', () => { + const { can, build } = abilityBuilder() + can('read', 'Post', { + thread: { + is: { + id: 0, + creator: { + is: { + id: 0 + } + } + } + } + }) + const { args, mask } = applyRuleRelationsQuery({ select: { id: true, thread: true } }, build(), 'read', 'Post') + expect(args).toEqual({ + select: { + id: true, thread: { + include: { + creator: { + select: { + id: true + } + } + } + } + } + }) + expect(mask).toEqual({ thread: { creator: true } }) + }) + + +}) \ No newline at end of file diff --git a/test/extension.test.ts b/test/extension.test.ts index e7a2c20..cd23c77 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -80,14 +80,11 @@ describe('prisma extension casl', () => { }) - expect(result).toEqual({ author: { email: '1', id: 1 } }) + expect(result).toEqual({ id: 1, author: { email: '1', id: 1 } }) const result2 = await client.post.findUnique({ where: { - id: 1, - thread: { - creatorId: 0 - } + id: 0 }, select: { id: true, @@ -96,9 +93,11 @@ describe('prisma extension casl', () => { }) - expect(result2).toEqual({ author: { email: '1', id: 1 } }) + expect(result2).toEqual({ id: 0, author: { email: '0' } }) }) + + it('does not include nested fields if query does not include properties to check for rules', async () => { const { can, build } = abilityBuilder() can('read', 'Post', ['id'], { @@ -131,7 +130,7 @@ describe('prisma extension casl', () => { } }, }) - expect(result).toEqual({ author: { email: '1', posts: [] } }) + expect(result).toEqual({ author: { email: '1' } }) }) it('includes nested fields if query does not include properties to check for rules', async () => { @@ -430,6 +429,22 @@ describe('prisma extension casl', () => { const result = await client.post.findMany({ include: { thread: true } }) expect(result).toEqual([{ authorId: 0, id: 0, text: '', threadId: 0, thread: { id: 0 } }, { authorId: 1, id: 1, text: '', threadId: 0, thread: { id: 0 } }, { authorId: 0, id: 3, text: '', threadId: 2, thread: { id: 2 } }]) }) + it('checks post permission but does not include it in output', async () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'User', 'email', { + posts: { + some: { + authorId: 0 + } + } + }) + cannot('read', 'Post') + const client = seedClient.$extends( + useCaslAbilities(build) + ) + const result = await client.user.findMany() + expect(result).toEqual([{ email: "0" }]) + }) }) describe('update', () => {