diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..76c3471 --- /dev/null +++ b/.vscode/settings.json @@ -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" +} diff --git a/README.md b/README.md index 3fb1e99..06589cc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # 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] > @@ -9,146 +8,93 @@ > > 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 - }) -``` \ No newline at end of file +`can('read', 'User', ['nested.field', 'field.*'])` probably won't work. diff --git a/src/applyAccessibleQuery.ts b/src/applyAccessibleQuery.ts index fbcbe11..f6b00aa 100644 --- a/src/applyAccessibleQuery.ts +++ b/src/applyAccessibleQuery.ts @@ -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 - ] - - } - } \ No newline at end of file + ...query, + AND: [ + ...(query.AND ?? []), + accessibleQuery + ] + + } +} \ No newline at end of file diff --git a/src/applyCaslToQuery.ts b/src/applyCaslToQuery.ts index a53f8f9..ae9fc34 100644 --- a/src/applyCaslToQuery.ts +++ b/src/applyCaslToQuery.ts @@ -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 @@ -17,14 +16,11 @@ import { PrismaQuery } from '@casl/prisma' * @returns Enriched query with casl authorization */ export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abilities: PureAbility, 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) } @@ -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 } \ No newline at end of file diff --git a/src/applyDataQuery.ts b/src/applyDataQuery.ts index 4a4ae90..3b3fdbf 100644 --- a/src/applyDataQuery.ts +++ b/src/applyDataQuery.ts @@ -1,8 +1,8 @@ -import { Prisma } from '@prisma/client' import { AbilityTuple, PureAbility } from '@casl/ability' import { accessibleBy, PrismaQuery } from '@casl/prisma' -import { caslNestedOperationDict, getPermittedFields, propertyFieldsByModel, relationFieldsByModel } from './helpers' +import { Prisma } from '@prisma/client' import { applyAccessibleQuery } from './applyAccessibleQuery' +import { caslNestedOperationDict, getPermittedFields, propertyFieldsByModel, relationFieldsByModel } from './helpers' /** * checks if mutation query is authorized by CASL @@ -26,84 +26,82 @@ export function applyDataQuery( action: string, model: string ) { - const permittedFields = getPermittedFields(abilities, args, action, model) - + const permittedFields = getPermittedFields(abilities, action, model) + const accessibleQuery = accessibleBy(abilities, action)[model as Prisma.ModelName] - const mutationArgs: any[] = [] - ;(Array.isArray(args) ? args : [args]).map((argsEntry)=>{ - let hasWhereQuery = false - // opt 1.: we either have mutations within data/create/update - // order is important for where query - ;['update', 'create', 'data'].forEach((nestedAction)=>{ - if(nestedAction in argsEntry){ - const nestedArgs = argsEntry[nestedAction] - Array.isArray(nestedArgs) ? mutationArgs.push(...nestedArgs) : mutationArgs.push(nestedArgs) - // if the mutation are not within args, we might also have a where query that needs to consider abilities - if(!hasWhereQuery && 'where' in argsEntry){ - hasWhereQuery = true - // if nestedAction is update, we probably have upsert - // if nestedAction is create, we probably have connectOrCreate - // therefore we check for 'update' accessibleQuery - argsEntry.where = applyAccessibleQuery(argsEntry.where, - nestedAction !== 'update' && nestedAction !== 'create' ? accessibleQuery : accessibleBy(abilities, 'update')[model as Prisma.ModelName] - ) - } + ; (Array.isArray(args) ? args : [args]).map((argsEntry) => { + let hasWhereQuery = false + // opt 1.: we either have mutations within data/create/update + // order is important for where query + ;['update', 'create', 'data'].forEach((nestedAction) => { + if (nestedAction in argsEntry) { + const nestedArgs = argsEntry[nestedAction] + Array.isArray(nestedArgs) ? mutationArgs.push(...nestedArgs) : mutationArgs.push(nestedArgs) + // if the mutation are not within args, we might also have a where query that needs to consider abilities + if (!hasWhereQuery && 'where' in argsEntry) { + hasWhereQuery = true + // if nestedAction is update, we probably have upsert + // if nestedAction is create, we probably have connectOrCreate + // therefore we check for 'update' accessibleQuery + argsEntry.where = applyAccessibleQuery(argsEntry.where, + nestedAction !== 'update' && nestedAction !== 'create' ? accessibleQuery : accessibleBy(abilities, 'update')[model as Prisma.ModelName] + ) + } + } + }) + // opt 2.: or the argsEntry themselves are the mutation + if (mutationArgs.length === 0) { + mutationArgs.push(argsEntry) } + }) - // opt 2.: or the args themselves are the mutation - if(mutationArgs.length === 0){ - mutationArgs.push(args) - } - }) - /** now we go trough all mutation args and throw error if they have not permitted fields or continue in nested mutations */ - mutationArgs.map((mutation: any)=>{ - + mutationArgs.map((mutation: any) => { + // get all mutation arg fields and if they are short code connect (userId instead of user: { connect: id }), we convert it - const queriedFields = (mutation ? Object.keys(mutation) : []).map((field)=>{ + const queriedFields = (mutation ? Object.keys(mutation) : []).map((field) => { const relationModelId = propertyFieldsByModel[model][field] - if(relationModelId){ - mutation[relationModelId] = { connect: {id: mutation[field]} } + if (relationModelId) { + mutation[relationModelId] = { connect: { id: mutation[field] } } delete mutation[field] return relationModelId } else { return field } }) - queriedFields.forEach((field) => { const relationModel = relationFieldsByModel[model][field] // omit relation models also through i.e. stat if (permittedFields?.includes(field) === false && !relationModel) { // if fields are not permitted we throw an error and exit throw new Error(`It's not allowed to "${action}" "${field}" on "${model}"`) - }else if(relationModel){ - // if additional relations are found, we apply data query on them, too - Object.entries(mutation[field]).forEach(([nestedAction, nestedArgs])=>{ - if(nestedAction in caslNestedOperationDict){ - const mutationAction = caslNestedOperationDict[nestedAction] - const isConnection = nestedAction === 'connect' || nestedAction === 'disconnect' + } else if (relationModel) { + // if additional relations are found, we apply data query on them, too + Object.entries(mutation[field]).forEach(([nestedAction, nestedArgs]) => { + if (nestedAction in caslNestedOperationDict) { + const mutationAction = caslNestedOperationDict[nestedAction] + const isConnection = nestedAction === 'connect' || nestedAction === 'disconnect' + + mutation[field][nestedAction] = applyDataQuery(abilities, nestedArgs, mutationAction, relationModel.type) + // connection works like a where query, so we apply it + if (isConnection) { + const accessibleQuery = accessibleBy(abilities, mutationAction)[relationModel.type as Prisma.ModelName] - mutation[field][nestedAction] = applyDataQuery(abilities, nestedArgs, mutationAction, relationModel.type) - // connection works like a where query, so we apply it - if(isConnection){ - const accessibleQuery = accessibleBy(abilities, mutationAction)[relationModel.type as Prisma.ModelName] - - if(Array.isArray(mutation[field][nestedAction])){ - mutation[field][nestedAction] = mutation[field][nestedAction].map((q)=>applyAccessibleQuery(q, accessibleQuery)) - }else{ - mutation[field][nestedAction] = applyAccessibleQuery(mutation[field][nestedAction], accessibleQuery) - } + if (Array.isArray(mutation[field][nestedAction])) { + mutation[field][nestedAction] = mutation[field][nestedAction].map((q) => applyAccessibleQuery(q, accessibleQuery)) + } else { + mutation[field][nestedAction] = applyAccessibleQuery(mutation[field][nestedAction], accessibleQuery) } - }else{ - throw new Error(`Unknown nested action ${nestedAction} on ${model}`) } - }) - } - }) - + } else { + throw new Error(`Unknown nested action ${nestedAction} on ${model}`) + } + }) + } }) - return args + + }) + return args } \ No newline at end of file diff --git a/src/applyIncludeSelectQuery.ts b/src/applyIncludeSelectQuery.ts index abe3a77..495f070 100644 --- a/src/applyIncludeSelectQuery.ts +++ b/src/applyIncludeSelectQuery.ts @@ -1,7 +1,7 @@ import { AbilityTuple, PureAbility } from '@casl/ability' import { PrismaQuery } from '@casl/prisma' -import { relationFieldsByModel } from './helpers' import { applyWhereQuery } from './applyWhereQuery' +import { relationFieldsByModel } from './helpers' /** diff --git a/src/applySelectPermittedFields.ts b/src/applySelectPermittedFields.ts index 0a3ce82..d626891 100644 --- a/src/applySelectPermittedFields.ts +++ b/src/applySelectPermittedFields.ts @@ -15,8 +15,8 @@ import { getPermittedFields, relationFieldsByModel } from './helpers' * @returns enriched query with selection of fields considering casl authorization */ export const applySelectPermittedFields = (abilities: PureAbility, args: any, model: string) => { - - const permittedFields = getPermittedFields(abilities, args, 'read', model) + + const permittedFields = getPermittedFields(abilities, 'read', model) if (permittedFields) { // prepare select statement and transform include to select if necessary if (args === true) { diff --git a/src/applyWhereQuery.ts b/src/applyWhereQuery.ts index 060ca85..3e09b7f 100644 --- a/src/applyWhereQuery.ts +++ b/src/applyWhereQuery.ts @@ -1,10 +1,9 @@ -import { Prisma } from '@prisma/client' import { AbilityTuple, PureAbility } from '@casl/ability' import { accessibleBy, PrismaQuery } from '@casl/prisma' -import { applySelectPermittedFields } from './applySelectPermittedFields' -import { relationFieldsByModel } from './helpers' +import { Prisma } from '@prisma/client' import { applyAccessibleQuery } from './applyAccessibleQuery' +import { relationFieldsByModel } from './helpers' /** @@ -53,7 +52,7 @@ export function applyWhereQuery( // if we add a where clause to a relation // we fake the where query, since it is otherwise buried in AND: [ OR: ...] // to get the select query - const method = args.include ? "include" : "select" + /*const method = args.include ? "include" : "select" const selectQuery = applySelectPermittedFields(abilities, { @@ -72,10 +71,10 @@ export function applyWhereQuery( select: selectQuery.select } } - } + }*/ - return result + return args//result } else { - return applySelectPermittedFields(abilities, args, model) + return args//applySelectPermittedFields(abilities, args, model) } } \ No newline at end of file diff --git a/src/filterQueryResults.ts b/src/filterQueryResults.ts new file mode 100644 index 0000000..a555e72 --- /dev/null +++ b/src/filterQueryResults.ts @@ -0,0 +1,33 @@ +import { AbilityTuple, PureAbility } from "@casl/ability"; +import { PrismaQuery } from "@casl/prisma"; +import { Prisma } from "@prisma/client"; +import { getPermittedFields, relationFieldsByModel } from "./helpers"; + +export function filterQueryResults(result: 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`) + } + + 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) { + delete entry[field] + } else if (relationField) { + hasKeys = true + entry[field] = filterQueryResults(entry[field], abilities, relationField.type) + } 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) +} \ No newline at end of file diff --git a/src/helpers.ts b/src/helpers.ts index add5ad0..97e6d20 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,8 +1,8 @@ +import { AbilityTuple, PureAbility, Subject, subject } from '@casl/ability' +import { permittedFieldsOf } from '@casl/ability/extra' +import { prismaQuery, PrismaQuery } from '@casl/prisma' import { Prisma } from '@prisma/client' import { DMMF } from '@prisma/generator-helper' -import { AbilityTuple, PureAbility } from '@casl/ability' -import { permittedFieldsOf } from '@casl/ability/extra' -import { PrismaQuery } from '@casl/prisma' type DefaultCaslAction = "create" | "read" | "update" | "delete" @@ -52,12 +52,12 @@ export const caslOperationDict: Record< } as const export const caslNestedOperationDict: Record = { - upsert: 'create', - connect: 'update', - connectOrCreate:'create', - create: 'create', - createMany: 'create', - update: 'update', + upsert: 'create', + connect: 'update', + connectOrCreate: 'create', + create: 'create', + createMany: 'create', + update: 'update', updateMany: 'update', delete: 'delete', deleteMany: 'delete', @@ -76,12 +76,21 @@ export const propertyFieldsByModel = Object.fromEntries(Prisma.dmmf.datamodel.mo const propertyFields = Object.fromEntries(model.fields .filter((field) => !(field && field.kind === 'object' && field.relationName)) .map((field) => { - const relation = Object.values(relationFieldsByModel[model.name]).find((value: any)=>value.relationFromFields.includes(field.name)) + const relation = Object.values(relationFieldsByModel[model.name]).find((value: any) => value.relationFromFields.includes(field.name)) return [field.name, relation?.name] })) return [model.name, propertyFields] })) +export function pick, K extends keyof T>(obj: T | undefined, keys: K[]) { + return keys.reduce((acc, val) => { + if (obj && val in obj) { + (acc[val] = obj[val]); + } + return acc; + }, {} as Pick); +} + /** * goes through all permitted fields of a model * @@ -94,63 +103,50 @@ export const propertyFieldsByModel = Object.fromEntries(Prisma.dmmf.datamodel.mo * the result is undefined. allowing us to query for all fields * * @param abilities - * @param args * @param action * @param model + * @param obj * @returns */ export function getPermittedFields( abilities: PureAbility, - args: any, action: string, - model: string -){ - let hasPermittedFields = false - let hasNoRuleWithoutFields: boolean | undefined = undefined - const omittedFieldsSet = new Set() - const permittedFields = permittedFieldsOf(abilities, action, model, { - fieldsFrom: rule => { - if(hasNoRuleWithoutFields === undefined){ - // make assumption true on first call of fieldsFrom - hasNoRuleWithoutFields = true - } - if (rule.fields) { - if(rule.inverted){ - rule.fields.forEach((field)=>omittedFieldsSet.add(field)) - } else { - hasPermittedFields = true - } - if (rule.conditions) { - if (isSubset(rule.conditions, args.where)) { - return rule.fields - } - // else if(process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { - // const queriedFields = args.select ? Object.keys(args.select) : args.include ? Object.keys(args.include) : [] + model: string, + obj?: any +) { + const modelFields = Object.keys(propertyFieldsByModel[model]) + const subjectFields = [...modelFields, ...Object.keys(relationFieldsByModel[model])] - // if(queriedFields.findIndex((field)=> rule.fields?.includes(field)) > -1){ - // console.warn(`${model} fields ${JSON.stringify(rule.fields)} can only be read with following conditions: ${JSON.stringify(rule.conditions)}`) - // } - // } - } else { - return rule.fields - } - }else{ - hasNoRuleWithoutFields = false - } - return [] + const permittedFields = permittedFieldsOf(abilities, action, obj ? subject(model, pick(obj, subjectFields)) : model, { + fieldsFrom: rule => { + return rule.fields || modelFields; } }) - - // if can rules allow read access on all properties, but some cannot rules omit fields, we add all permitted properties to select to create an inverted version - // newer prisma version will allow omit besides select for cleaner interface. - if(hasPermittedFields === false && permittedFields.length === 0 && omittedFieldsSet.size > 0){ - permittedFields.push(...Object.keys(propertyFieldsByModel[model]).filter((field)=> !omittedFieldsSet.has(field))) - hasPermittedFields = true - } - return hasPermittedFields && permittedFields.length > 0 ? permittedFields : hasNoRuleWithoutFields ? [] : undefined + + return permittedFields } +/** + * if fluent api is used `client.user.findUnique().post()` + * we need to get its model + * https://github.com/prisma/prisma/blob/cebc9c0ceb91ff9c80f0b149f3a7ff112fbb46fd/packages/client/src/runtime/core/model/applyFluent.ts#L20 + * @param startModel query model + * @param data query args with internalParams - includes a dataPath for fluent api + * @returns fluent api model + */ +export function getFluentModel(startModel: string, data: any) { + const dataPath = data?.__internalParams?.dataPath as string[] + if (dataPath?.length > 0) { + return dataPath.filter((x) => x !== 'select').reduce((acc, x) => { + acc = relationFieldsByModel[acc][x].type + return acc + }, startModel) + } else { + return startModel + } +} + export function isSubset(obj1: any, obj2: any): boolean { if (obj1 === obj2) return true; diff --git a/src/index.ts b/src/index.ts index 9df1a62..effe966 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ -import { Prisma } from '@prisma/client' import { AbilityTuple, PureAbility } from '@casl/ability' import { PrismaQuery } from '@casl/prisma' +import { Prisma } from '@prisma/client' import { applyCaslToQuery } from './applyCaslToQuery' +import { filterQueryResults } from './filterQueryResults' +import { getFluentModel } from './helpers' export { applyCaslToQuery } + /** * enrich a prisma client to check for CASL abilities even in nested queries * @@ -17,58 +20,58 @@ export { applyCaslToQuery } * - this is a function call to instantiate abilities on each prisma query to allow adding i.e. context or claims * @returns enriched prisma client */ -export const useCaslAbilities = (getAbilities: ()=> PureAbility) =>{ +export const useCaslAbilities = (getAbilities: () => PureAbility) => { return Prisma.defineExtension({ name: "prisma-extension-casl", query: { $allModels: { - create ({ args, query, model, operation }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + create({ args, query, model, operation, ...rest }) { + return query(applyCaslToQuery(operation, args, getAbilities(), model)).then((result) => filterQueryResults(result, getAbilities(), getFluentModel(model, rest))) }, - createMany ({ args, query, model, operation }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }){ - return query(applyCaslToQuery(operation, args, getAbilities(), model)) + 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 }) { diff --git a/test/abilities.ts b/test/abilities.ts index 61191a2..c0ef9cc 100644 --- a/test/abilities.ts +++ b/test/abilities.ts @@ -4,21 +4,21 @@ import { PrismaQuery, Subjects, } from '@casl/prisma' -import { Post, Thread, User, Topic } from '@prisma/client' +import { Post, Thread, Topic, User } from '@prisma/client' type AppAbility = PureAbility< [ string, Subjects<{ - Post: Post, - Thread: Thread, - User: User, - Topic: Topic + Post: Post, + Thread: Thread, + User: User, + Topic: Topic }> ], PrismaQuery > -export function abilityBuilder(){ - return new AbilityBuilder(createPrismaAbility) +export function abilityBuilder() { + return new AbilityBuilder(createPrismaAbility) } diff --git a/test/applyCaslToQuery.test.ts b/test/applyCaslToQuery.test.ts index 53efa6c..d50b51c 100644 --- a/test/applyCaslToQuery.test.ts +++ b/test/applyCaslToQuery.test.ts @@ -1,11 +1,11 @@ -import { abilityBuilder } from './abilities' import { applyCaslToQuery } from '../src/applyCaslToQuery' import { caslOperationDict, PrismaCaslOperation } from '../src/helpers' +import { abilityBuilder } from './abilities' describe('apply casl to query', () => { - it('adds permitted field to query and turns include into select', async () => { + it('does not add conditions if there are none on abilities', async () => { const { can, build } = abilityBuilder() can('read', 'Post', ['id']) can('read', 'User') @@ -20,11 +20,7 @@ describe('apply casl to query', () => { }, abilities, 'User') expect(result).toEqual({ include: { - posts: { - select: { - id: true - } - } + posts: true }, where: { id: 1 @@ -33,7 +29,7 @@ describe('apply casl to query', () => { }) - it('applies nested select and where and removes model if query does not fit', async () => { + it('applies nested select and where', async () => { const { can, build } = abilityBuilder() can('read', 'Post', ['id'], { thread: { @@ -59,82 +55,20 @@ describe('apply casl to query', () => { }, }, abilities, 'Post') expect(result).toEqual({ - select: { - author: { - select: { - email: true, - posts: false - } - } - }, - where: { - AND: [{ - OR: [{ - thread: { - creatorId: 0 - } - } - ] - }], - id: 1, - } - }) - - }) - it('applies nested select and where and adds property if query fits', async () => { - const { can, build } = abilityBuilder() - can('read', 'Post', ['id'], { - thread: { - creatorId: 0 - } - }) - can('read', 'User', ['email', 'id'], { - id: 2 - }) - can('read', 'User', ['email']) - const abilities = build() - const result = applyCaslToQuery('findUnique', { - where: { - id: 1 - }, select: { author: { select: { email: true, posts: { where: { - thread: { - creatorId: 0 - } - } - } - }, - } - }, - }, abilities, 'Post') - expect(result).toEqual({ - select: { - author: { - select: { - email: true, - posts: { - select: { - id: true, - }, - where: { - thread: { - creatorId: 0 - }, AND: [{ OR: [{ thread: { creatorId: 0 } - } - ] - }], + }] + }] } - } } } @@ -151,11 +85,12 @@ describe('apply casl to query', () => { id: 1, } }) + }) - it('ignores conditional rule, if not part of query', ()=>{ + it('ignores conditional rule', () => { const { can, build } = abilityBuilder() - can('read', 'all' as any) - can('read', 'User', ['email', 'id'], { + can('read', 'User' as any) + can('read', 'User', ['email'], { id: 0 }) const abilities = build() @@ -163,23 +98,36 @@ describe('apply casl to query', () => { expect(result).toEqual({}) }) - it('applies filter props and ignores weaker can rule', ()=>{ + // 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', () => { 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({ select: { email: true, id: true }}) - }) - it('allows to see more props on a condition', ()=>{ - const { can, build } = abilityBuilder() - can('read', 'User', 'email') - can('read', 'User', ['email', 'id'], {id:0}) + + can('read', 'User', 'email', { id: 0 }) const abilities = build() - const result = applyCaslToQuery('findUnique', { where: { id: 0 } }, abilities, 'User') - expect(result).toEqual({ where: { id: 0 }, select: { email: true, id: true }}) + const result = applyCaslToQuery('findUnique', { + select: { + email: true + } + + }, abilities, 'User') + expect(result).toEqual({ where: { AND: [{ OR: [{ id: 0 }] }] }, select: { email: 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`, () => { @@ -193,14 +141,14 @@ describe('apply casl to query', () => { const abilities = build() const result = applyCaslToQuery(operation as PrismaCaslOperation, { - ...(settings.dataQuery ? { data: { id: 0 } }: {}), + ...(settings.dataQuery ? { data: { id: 0 } } : {}), ...(settings.includeSelectQuery ? { include: { posts: true } } : {}) }, abilities, 'User') if (settings.dataQuery) { - expect(result.data).toEqual({ id: 0}) - }else { + expect(result.data).toEqual({ id: 0 }) + } else { expect(result.data).toBeUndefined() } if (settings.whereQuery) { @@ -209,7 +157,7 @@ describe('apply casl to query', () => { expect(result.where).toBeUndefined() } if (settings.includeSelectQuery) { - expect(result.include).toEqual({ posts: { select: { id: true } } }) + expect(result.include).toEqual({ posts: true }) expect(result.select).toBeUndefined() } else { expect(result.include).toBeUndefined() diff --git a/test/applyDataQuery.test.ts b/test/applyDataQuery.test.ts index 9a1277c..9bbac7b 100644 --- a/test/applyDataQuery.test.ts +++ b/test/applyDataQuery.test.ts @@ -1,6 +1,6 @@ -import { abilityBuilder } from './abilities' import { applyDataQuery } from '../src/applyDataQuery' +import { abilityBuilder } from './abilities' describe('apply data query', () => { diff --git a/test/applyIncludeSelectQuery.test.ts b/test/applyIncludeSelectQuery.test.ts index bea0989..09283f0 100644 --- a/test/applyIncludeSelectQuery.test.ts +++ b/test/applyIncludeSelectQuery.test.ts @@ -1,6 +1,6 @@ -import { abilityBuilder } from './abilities' import { applyIncludeSelectQuery } from '../src/applyIncludeSelectQuery' +import { abilityBuilder } from './abilities' describe('apply include select query', () => { it('applies select method', () => { @@ -16,11 +16,7 @@ describe('apply include select query', () => { }, 'Post') expect(args).toEqual({ select: { - author: { - select: { - email: true - } - } + author: true }, where: { AND: [{ @@ -31,11 +27,12 @@ describe('apply include select query', () => { } }) }) - it.todo('applies select method'/*, () => { + it('applies select method', () => { const { can, cannot, build } = abilityBuilder() can('read', 'User', { id: 1 }) + // this has to be filtered after query. cannot('read', 'User', 'email', { id: 0 }) @@ -46,22 +43,19 @@ describe('apply include select query', () => { }, 'Post') expect(args).toEqual({ select: { - author: { - select: { - email: true - } - } + author: true }, where: { AND: [{ author: { OR: [{ id: 1 }], - AND: [{ NOT: { id: 0 } }] + // AND: [{ NOT: { id: 0 } }] } }] } }) - }*/) + }) + it('applies select method and does not allow reading of field', () => { const { can, cannot, build } = abilityBuilder() can('read', 'User') @@ -80,9 +74,7 @@ describe('apply include select query', () => { }, 'Post') expect(args).toEqual({ select: { - author: { - select: undefined - } + author: true }, where: { author: { @@ -103,7 +95,11 @@ describe('apply include select query', () => { }, 'User') expect(args).toEqual({ select: { - posts: false + posts: { + where: { + AND: [{ OR: [{ authorId: 0 }] }] + } + } } }) }) @@ -126,9 +122,6 @@ describe('apply include select query', () => { where: { authorId: 0, AND: [{ OR: [{ authorId: 0 }] }] - }, - select: { - id: true } } } @@ -147,11 +140,7 @@ describe('apply include select query', () => { }, 'Post') expect(args).toEqual({ include: { - author: { - select: { - email: true - } - } + author: true }, where: { AND: [{ @@ -162,7 +151,7 @@ describe('apply include select query', () => { } }) }) - + it('applies select method on array', () => { const { can, cannot, build } = abilityBuilder() can('read', 'Post', 'id', { @@ -175,7 +164,11 @@ describe('apply include select query', () => { }, 'User') expect(args).toEqual({ include: { - posts: false + posts: { + where: { + AND: [{ OR: [{ authorId: 0 }] }] + } + } } }) }) @@ -199,9 +192,6 @@ describe('apply include select query', () => { where: { authorId: 0, AND: [{ OR: [{ authorId: 0 }] }] - }, - select: { - id: true } } } diff --git a/test/applySelectPermittedFields.test.ts b/test/applySelectPermittedFields.test.ts index 27ec713..23a7946 100644 --- a/test/applySelectPermittedFields.test.ts +++ b/test/applySelectPermittedFields.test.ts @@ -5,33 +5,25 @@ import { applySelectPermittedFields } from '../src/applySelectPermittedFields' describe('apply select to permitted fields', () => { - it('adds select if args are true', () => { + it('adds all permitted fields to select if args are true', () => { const { can, build } = abilityBuilder() can('read', 'User', ['email', 'id'], { id: 0 }) can('read', 'User', ['email']) const args = applySelectPermittedFields(build(), true, 'User') - expect(args.select).toEqual({ email: true }) + expect(args.select).toEqual({ email: true, id: true }) }) - it('adds select query if missing ', () => { + it('adds all permitted fields to select if query if missing ', () => { const { can, build } = abilityBuilder() can('read', 'User', ['email', 'id'], { id: 0 }) can('read', 'User', ['email']) const args = applySelectPermittedFields(build(), {}, 'User') - expect(args.select).toEqual({ email: true }) - }) - it('removes unwanted fields', () => { - const { can, build } = abilityBuilder() - can('read', 'User', ['email', 'id'], { - id: 0 - }) - can('read', 'User', ['email']) - const args = applySelectPermittedFields(build(), { select: { x: 0 } }, 'User') - expect(args.select).toEqual({ email: true }) + expect(args.select).toEqual({ email: true, id: true }) }) + it('converts include to select ', () => { const { can, build } = abilityBuilder() can('read', 'User', ['email', 'id'], { @@ -39,28 +31,28 @@ describe('apply select to permitted fields', () => { }) can('read', 'User', ['email']) const args = applySelectPermittedFields(build(), { include: { x: 0 } }, 'User') - expect(args.select).toEqual({ email: true }) + expect(args.select).toEqual({ email: true, id: true }) }) - it('allows more permitted fields, if query matches', () => { - const { can, build } = abilityBuilder() + it('does not add restricted fields to select query', () => { + const { can, cannot, build } = abilityBuilder() can('read', 'User', ['email', 'id'], { id: 0 }) - can('read', 'User', ['email']) - const args = applySelectPermittedFields(build(), { where: { id: 0 } }, 'User') - expect(args.select).toEqual({ email: true, id: true }) + cannot('read', 'User', ['email']) + const args = applySelectPermittedFields(build(), { }, 'User') + expect(args.select).toEqual({ id: true }) }) - it('allows permitted fields if cannot rule does not apply', () => { + it('does not consider restricted fields when condition applies', () => { + // conditional fields need to be filtered after query const { can, cannot, build } = abilityBuilder() - cannot('read', 'User', ['email'], { + can('read', 'User', ['email'], { id: 0 }) - can('read', 'User', ['email'], { + cannot('read', 'User', ['email'], { id: 1 }) - const args = applySelectPermittedFields(build(), { where: { id: 0 } }, 'User') - expect(args).toEqual({ select: {}, where: { id: 0 } }) - const args2 = applySelectPermittedFields(build(), { where: { id: 1 } }, 'User') - expect(args2).toEqual({ where: { id: 1 }, select: { email: true } }) + const args = applySelectPermittedFields(build(), { }, 'User') + expect(args.select).toEqual({ email: true}) }) + }) \ No newline at end of file diff --git a/test/applyWhereQuery.test.ts b/test/applyWhereQuery.test.ts index fbd4a0f..1be8d2c 100644 --- a/test/applyWhereQuery.test.ts +++ b/test/applyWhereQuery.test.ts @@ -1,6 +1,6 @@ -import { abilityBuilder } from './abilities' import { applyWhereQuery } from '../src/applyWhereQuery' +import { abilityBuilder } from './abilities' describe('apply where query', () => { @@ -13,7 +13,7 @@ describe('apply where query', () => { id: 1 }) const args = applyWhereQuery(build(), true, 'read', 'User') - expect(args.where).toEqual({ AND: [{ OR: [{ id: 1 }], AND: [{ NOT: { id: 0 } }] }] }) + expect(args).toEqual({ where: { AND: [{ OR: [{ id: 1 }], AND: [{ NOT: { id: 0 } }] }] } }) }) it('adds where query if missing', () => { const { can, build } = abilityBuilder() @@ -21,7 +21,16 @@ describe('apply where query', () => { id: 0 }) const args = applyWhereQuery(build(), {}, 'read', 'User') - expect(args.where).toEqual({ AND: [{ OR: [{ id: 0 }] }] }) + expect(args).toEqual({ where: { AND: [{ OR: [{ id: 0 }] }] } }) + }) + it('does not add where query if there is no condition', () => { + const { can, build } = abilityBuilder() + can('read', 'User') + can('read', 'User', ['email'], { + id: 0 + }) + const args = applyWhereQuery(build(), {}, 'read', 'User') + expect(args).toEqual({}) }) it('adds to existing where query', () => { const { can, build } = abilityBuilder() @@ -34,9 +43,9 @@ describe('apply where query', () => { AND: [{ id: 1 }] } }, 'read', 'User') - expect(args.where).toEqual({ id: 1, AND: [{ id: 1 }, { OR: [{ id: 0 }] }] }) + expect(args).toEqual({ where: { id: 1, AND: [{ id: 1 }, { OR: [{ id: 0 }] }] } }) }) - it('adds permitted field to query and turns include into select', () => { + it('does not alter where query if there is not condition', () => { const { can, cannot, build } = abilityBuilder() can('read', 'User', ['email']) can('read', 'Post') @@ -53,8 +62,7 @@ describe('apply where query', () => { where: { id: 1, AND: [{ id: 1 }] }, - select: { - email: true, + include: { posts: true } }) diff --git a/test/extension.test.ts b/test/extension.test.ts index 47413a2..e7a2c20 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -2,8 +2,9 @@ import { seedClient } from './client' import { seed } from './seed' -import { abilityBuilder } from './abilities' +import { prismaQuery } from '@casl/prisma' import { useCaslAbilities } from '../src/index' +import { abilityBuilder } from './abilities' beforeEach(async () => { @@ -42,7 +43,7 @@ describe('prisma extension casl', () => { can('read', 'User') cannot('read', 'User', ['email']) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) expect(await client.user.findUnique({ where: { @@ -54,7 +55,10 @@ describe('prisma extension casl', () => { const { can, build } = abilityBuilder() can('read', 'Post', ['id'], { thread: { - creatorId: 0 + //https://casl.js.org/v6/en/package/casl-prisma#note-on-prisma-query-runtime-interpreter + is: { + creatorId: 0 + } } }) can('read', 'User', ['email', 'id'], { @@ -62,7 +66,7 @@ describe('prisma extension casl', () => { }) can('read', 'User', ['email']) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.post.findUnique({ @@ -76,7 +80,7 @@ describe('prisma extension casl', () => { }) - expect(result).toEqual({ author: { email: '1' } }) + expect(result).toEqual({ author: { email: '1', id: 1 } }) const result2 = await client.post.findUnique({ where: { @@ -92,22 +96,25 @@ describe('prisma extension casl', () => { }) - expect(result2).toEqual({ id: 1, author: { email: '1' } }) + expect(result2).toEqual({ author: { email: '1', id: 1 } }) }) - it('only adds permitted fields in nested select if where clause matche abilities', async () => { + 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'], { thread: { - creatorId: 0 + is: { + creatorId: 0 + } } }) + can('read', 'Thread', ['creatorId']) can('read', 'User', ['email', 'id'], { id: 2 }) can('read', 'User', ['email']) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) @@ -124,9 +131,26 @@ describe('prisma extension casl', () => { } }, }) - expect(result).toEqual({ author: { email: '1' } }) + expect(result).toEqual({ author: { email: '1', posts: [] } }) + }) - const t1 = performance.now() + it('includes nested fields if query does not include properties to check for rules', async () => { + const { can, build } = abilityBuilder() + can('read', 'Post', ['id'], { + thread: { + is: { + creatorId: { lte: 0 } + } + } + }) + can('read', 'Thread', ['creatorId']) + can('read', 'User', ['email', 'id'], { + id: 2 + }) + can('read', 'User', ['email']) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) const result2 = await client.post.findUnique({ where: { id: 1 @@ -136,6 +160,13 @@ describe('prisma extension casl', () => { select: { email: true, posts: { + include: { + thread: { + select: { + creatorId: true + } + } + }, where: { thread: { creatorId: 0 @@ -146,8 +177,27 @@ describe('prisma extension casl', () => { } }, }) - const t2 = performance.now() - const result3 = await seedClient.post.findUnique({ + + expect(result2).toEqual({ author: { email: '1', posts: [{ 'id': 1, thread: { creatorId: 0 } }] } }) + }) + it('includes nested fields if query does not include properties to check for rules', async () => { + const { can, build } = abilityBuilder() + can('read', 'Post', ['id'], { + thread: { + is: { + creatorId: 0 + } + } + }) + can('read', 'Thread', ['id']) + can('read', 'User', ['email', 'id'], { + id: 2 + }) + can('read', 'User', ['email']) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result2 = await client.post.findUnique({ where: { id: 1 }, @@ -156,6 +206,9 @@ describe('prisma extension casl', () => { select: { email: true, posts: { + include: { + thread: true + }, where: { thread: { creatorId: 0 @@ -166,44 +219,163 @@ describe('prisma extension casl', () => { } }, }) - const t3 = performance.now() - console.log("performance", t3 - t2, t2 - t1) - expect(result2).toEqual({ author: { email: '1', posts: [{ 'id': 1 }] } }) + expect(result2).toEqual({ author: { email: '1', posts: [{ 'id': 1, thread: { id: 0 } }] } }) + }) + + it('ignores conditional rule', async () => { + const { can, build } = abilityBuilder() + can('read', 'User' as any) + can('read', 'User', ['email'], { + id: 0 + }) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.user.findMany() + expect(result).toEqual([{ email: '0', id: 0 }, { email: '1', id: 1 }]) + }) + + it('ignores conditional rule', async () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'User') + cannot('read', 'User', 'email', { + id: 0 + }) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.user.findMany() + expect(result).toEqual([{ id: 0 }, { email: '1', id: 1 }]) + }) + it('applies filter props and ignores weaker can rule', async () => { + const { can, build } = abilityBuilder() + can('read', 'User', 'email') + can('read', 'User', { + id: 0 + }) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.user.findMany() + expect(result).toEqual([{ email: '0', id: 0 }, { email: '1' }]) + }) + it('allows to see more props on a condition', async () => { + const { can, build } = abilityBuilder() + can('read', 'User', 'email') + can('read', 'User', ['id'], { id: 0 }) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.user.findMany() + expect(result).toEqual([{ email: '0', id: 0 }, { email: '1' }]) + }) + it('allows to see only specified props on a condition', async () => { + const { can, build } = abilityBuilder() + + can('read', 'User', 'email', { id: 0 }) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.user.findMany() + + expect(result).toEqual([{ 'email': '0' }]) }) + it('allows to see more props on a condition', async () => { + const { can, build } = abilityBuilder() + can('read', 'User', 'id', { id: 1 }) + can('read', 'User', 'email', { id: 0 }) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.user.findMany() + expect(result).toEqual([{ 'email': '0' }, { id: 1 }]) + }) + it('can findUnique if nested id is correct and included', async () => { + const { can, build } = abilityBuilder() + can('read', 'Post', { + thread: { + is: { + creatorId: 0 + } + } + }) + can('read', 'Thread', 'id') + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.post.findUnique({ where: { id: 0 }, include: { thread: true } }) + expect(result).toEqual({ authorId: 0, id: 0, threadId: 0, text: '', thread: { id: 0 } }) + }) + it('cannot findUnique if nested id is correct and included, but nested has no read rights', async () => { + const { can, build } = abilityBuilder() + can('read', 'Post', { + thread: { + is: { + creatorId: 0 + } + } + }) + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + await expect(client.post.findUnique({ where: { id: 0 }, include: { thread: true } })).rejects.toThrow() + }) + it('cannot findUnique if nested id is not correct and included', async () => { + const { can, build } = abilityBuilder() + can('read', 'Post', { + thread: { + is: { + creatorId: 0 + } + } + }) + can('read', 'Thread', 'id') + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) - it('can findUnique', async () => { + expect(await client.post.findUnique({ + where: { id: 2 }, + include: { + thread: true + } + })).toBeNull() + }) + it('cannot findUnique if nested id is not readable', async () => { const { can, build } = abilityBuilder() can('read', 'Post', { thread: { - creatorId: 0 + is: { + creatorId: 0 + } } }) + can('read', 'Thread', 'id') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) - const result = await client.post.findUnique({ where: { id: 0 } }) - expect(result).toEqual({ authorId: 0, id: 0, threadId: 0, text: '' }) - // expect(result?.authorId).toBe(0) expect(await client.post.findUnique({ - where: { id: 2 } + where: { id: 2 }, })).toBeNull() }) it('can findUnique include', async () => { const { can, build } = abilityBuilder() can('read', 'Post', { thread: { - creatorId: 0 + is: { + creatorId: 0 + } } }) can('read', 'User', { id: 0 }) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.post.findUnique({ where: { id: 0 }, @@ -226,11 +398,13 @@ describe('prisma extension casl', () => { can('read', 'Post') cannot('read', 'Post', { thread: { - creatorId: 0 + is: { + creatorId: 0 + } } }) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.post.findUnique({ where: { id: 2 } }) expect(result?.authorId).toBe(1) @@ -244,14 +418,17 @@ describe('prisma extension casl', () => { const { can, build } = abilityBuilder() can('read', 'Post', { thread: { - creatorId: 0 + is: { + creatorId: 0 + } } }) + can('read', 'Thread', 'id') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) - const result = await client.post.findMany() - expect(result).toEqual([{ authorId: 0, id: 0, text: '', threadId: 0 }, { authorId: 1, id: 1, text: '', threadId: 0 }, { authorId: 0, id: 3, text: '', threadId: 2 }]) + 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 } }]) }) }) @@ -260,7 +437,7 @@ describe('prisma extension casl', () => { const { build } = abilityBuilder() const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.update({ @@ -272,11 +449,11 @@ describe('prisma extension casl', () => { } })).rejects.toThrow() }) - it('can update with ability', async () => { + it('can update with ability, but cannot read', async () => { const { can, build } = abilityBuilder() can('update', 'User') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.update({ data: { @@ -286,14 +463,14 @@ describe('prisma extension casl', () => { id: 0 } }) - expect(result).toEqual({ id: 0, email: 'new' }) + expect(result).toBeNull() }) it('can update with ability but only read permitted values', async () => { const { can, build } = abilityBuilder() can('read', 'User', ['email']) can('update', 'User') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.update({ data: { @@ -307,9 +484,10 @@ describe('prisma extension casl', () => { }) it('can update permitted property with ability', async () => { const { can, build } = abilityBuilder() + can('read', 'User') can('update', 'User', ['email']) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.update({ data: { @@ -325,7 +503,7 @@ describe('prisma extension casl', () => { const { can, build } = abilityBuilder() can('update', 'User', ['id']) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.update({ data: { @@ -341,7 +519,7 @@ describe('prisma extension casl', () => { can('update', 'User') cannot('update', 'User', ['email']) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.update({ data: { @@ -354,10 +532,11 @@ describe('prisma extension casl', () => { }) it('can update permitted property with ability and query included data', async () => { const { can, build } = abilityBuilder() + can('read', 'User', ['email']) can('update', 'User', ['email']) can('read', 'Post', ['id']) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.update({ data: { @@ -370,15 +549,16 @@ describe('prisma extension casl', () => { posts: true } }) - expect(result).toEqual({ email: 'new', id: 0, posts: [{ id: 0 }, { id: 3 }] }) + expect(result).toEqual({ email: 'new', posts: [{ id: 0 }, { id: 3 }] }) }) it('can do nested updates', async () => { const { can, build } = abilityBuilder() + can('read', 'User', 'email') can('update', 'User') can('update', 'Post') can('read', 'Post') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.update({ data: { @@ -403,14 +583,14 @@ describe('prisma extension casl', () => { } } }) - expect(result).toEqual({ email: 'new', id: 0, posts: [{ id: 0, text: '1' }, { id: 3, text: '' }] }) + expect(result).toEqual({ email: 'new', posts: [{ id: 0, text: '1' }, { id: 3, text: '' }] }) }) it('cannot do nested updates if no ability exists', async () => { const { can, build } = abilityBuilder() can('update', 'User') can('read', 'Post') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.update({ @@ -438,7 +618,7 @@ describe('prisma extension casl', () => { it('cannot delete if not ability exists', async () => { const { can, build } = abilityBuilder() const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.delete({ where: { @@ -446,11 +626,27 @@ describe('prisma extension casl', () => { } })).rejects.toThrow() }) - it('can delete if ability exists', async () => { + it('can delete if ability exists, but cannot read result', async () => { + const { can, build } = abilityBuilder() + can('delete', 'User') + const client = seedClient.$extends( + useCaslAbilities(() => build()) + ) + const result = await client.user.delete({ + where: { + id: 0 + } + }) + const deleted = await seedClient.user.findUnique({ where: { id: 0 } }) + expect(deleted).toBeNull() + expect(result).toBeNull() + }) + it('can delete if ability exists and can read result if ability exists', async () => { const { can, build } = abilityBuilder() + can('read', 'User') can('delete', 'User') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.delete({ where: { @@ -463,11 +659,12 @@ describe('prisma extension casl', () => { }) it('can delete if ability exists and condition applies', async () => { const { can, build } = abilityBuilder() + can('read', 'User') can('delete', 'User', { id: 0 }) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.delete({ where: { @@ -484,7 +681,7 @@ describe('prisma extension casl', () => { id: 1 }) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.delete({ where: { @@ -499,7 +696,7 @@ describe('prisma extension casl', () => { it('cannot delete many if not ability exists', async () => { const { can, build } = abilityBuilder() const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.deleteMany({ where: { @@ -511,7 +708,7 @@ describe('prisma extension casl', () => { const { can, build } = abilityBuilder() can('delete', 'User') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await client.user.deleteMany({ where: { @@ -530,7 +727,7 @@ describe('prisma extension casl', () => { } }) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await client.user.deleteMany({ where: { @@ -549,7 +746,7 @@ describe('prisma extension casl', () => { } }) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await client.user.deleteMany({ where: { @@ -561,24 +758,23 @@ describe('prisma extension casl', () => { expect(await seedClient.user.count()).toBe(1) }) }) - describe('chained queries', () => { + describe('fluent api queries', () => { it('can do chained queries if abilities exist', async () => { const { can, build } = abilityBuilder() can('read', 'User') + can('read', 'Thread') can('read', 'Post', { id: 0 }) const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) const result = await client.user.findUnique({ where: { id: 0 } }).posts() - expect(result?.length).toBe(1) - expect(result?.[0].threadId).toBe(0) - expect(result?.[0].authorId).toBe(0) + expect(result).toEqual([{ authorId: 0, id: 0, text: '', threadId: 0 }]) }) it('can do chained queries if abilities exist', async () => { const { can, build } = abilityBuilder() can('read', 'User') const client = seedClient.$extends( - useCaslAbilities(()=>build()) + useCaslAbilities(() => build()) ) await expect(client.user.findUnique({ where: { id: 0 } }).posts()).rejects.toThrow() diff --git a/test/getPermittedFields.test.ts b/test/getPermittedFields.test.ts new file mode 100644 index 0000000..b2635d3 --- /dev/null +++ b/test/getPermittedFields.test.ts @@ -0,0 +1,62 @@ +import { subject } from '@casl/ability' +import { permittedFieldsOf } from '@casl/ability/extra' +import { getPermittedFields } from '../src/helpers' +import { abilityBuilder } from './abilities' + + +describe('getPermittedFields', () => { + + + it('includes permitted fields', () => { + const { can, build } = abilityBuilder() + can('read', 'User', 'email') + const fields = getPermittedFields(build(), 'read', 'User') + expect(fields).toEqual(['email']) + }) + it('does not include restricted fields', () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'User') + cannot('read', 'User', 'id') + expect(getPermittedFields(build(), 'read', 'User')).toEqual(['email']) + }) + it('does not include restricted fields even if they exist in can', () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'User', ['email', 'id']) + cannot('read', 'User', 'id') + expect(getPermittedFields(build(), 'read', 'User')).toEqual(['email']) + }) + it('does not include restricted fields even if can condition applies', () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'User', ['email', 'id'], { id: 0 }) + cannot('read', 'User', 'id') + expect(getPermittedFields(build(), 'read', 'User', { id: 0 })).toEqual(['email']) + }) + it('does not include restricted fields even if they exist in can', () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'User', ['email', 'id'], { id: 0 }) + cannot('read', 'User', 'id') + expect(getPermittedFields(build(), 'read', 'User', { id: 0 })).toEqual(['email']) + }) + it('includes permitted fields if nested relation is valid', () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'Post', 'id', { + thread: { + is: { + creatorId: 0 + } + } + }) + //@ts-ignore + expect(permittedFieldsOf(build(), 'read', subject('Post', { thread: { creatorId: 0 } }), { fieldsFrom: (rule: any) => rule.fields || ['id', 'authorId', 'threadId'] })).toEqual(['id']) + expect(getPermittedFields(build(), 'read', 'Post', { thread: { creatorId: 0 } })).toEqual(['id']) + }) + it('prefers cannot conditions over can conditions ', () => { + const { can, cannot, build } = abilityBuilder() + can('read', 'User', 'email') + can('read', 'User', ['email', 'id'], { id: 0 }) + cannot('read', 'User', 'id', { email: '0' }) + expect(getPermittedFields(build(), 'read', 'User', { id: 0, email: '0' })).toEqual(['email']) + expect(getPermittedFields(build(), 'read', 'User', { id: 0, email: '1' })).toEqual(['email', 'id']) + expect(getPermittedFields(build(), 'read', 'User', { id: 1, email: '0' })).toEqual(['email']) + }) +}) \ No newline at end of file