From 7130a3367c9fe0810cc5406f861eee1e0991098c Mon Sep 17 00:00:00 2001 From: dennemark Date: Tue, 24 Sep 2024 15:39:19 +0200 Subject: [PATCH] feat: :sparkles: add possibility to store simplified crud rights on model --- README.md | 7 ++-- src/applyCaslToQuery.ts | 4 +-- src/applyRuleRelationsQuery.ts | 3 +- src/filterQueryResults.ts | 12 ++++--- src/index.ts | 9 +++-- src/storePermissions.ts | 25 ++++++++++++++ test/extension.test.ts | 61 ++++++++++++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 src/storePermissions.ts diff --git a/README.md b/README.md index bb6880d..92e70d0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - Mutating queries will throw errors in a similar format as CASL. `It's not allowed to "update" "email" on "User"`. - On nested `connect`, `disconnect`, `upsert` or `connectOrCreate` mutation queries the client assumes an `update` action for insertion or connection. - `update` and `create` are wrapped into a transaction, since `create` abilities will be checked on result of mutation and if it was not allowed the transaction will revert the creation. This limits client transactions to interactive transactions only. Sequential transactions are not supported. +- optionally add a `permissionField` to `useCaslAbilities` to get an array of crud rights of the queried model: `['create', 'read', 'update', 'delete']` which can be used to check abilities client-side conveniently without all the necessary relation queries ### Examples @@ -32,7 +33,9 @@ function builderFactory() { return builder; } -const caslClient = prismaClient.$extends(useCaslAbilities(builderFactory)); +const caslClient = prismaClient.$extends( + useCaslAbilities(builderFactory, "casl") +); const result = await caslClient.post.findMany({ include: { thread: true, @@ -56,7 +59,7 @@ const result = await caslClient.post.findMany({ * } * * and result will be filtered and should look like - * { id: 0, threadId: 0, thread: { id: 0 } } + * { id: 0, threadId: 0, thread: { id: 0 }, casl: ['read'] } */ ``` diff --git a/src/applyCaslToQuery.ts b/src/applyCaslToQuery.ts index f798cee..01cc6ab 100644 --- a/src/applyCaslToQuery.ts +++ b/src/applyCaslToQuery.ts @@ -17,7 +17,7 @@ import { transformDataToWhereQuery } from "./transformDataToWhereQuery" * @param model Prisma model * @returns Enriched query with casl authorization */ -export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abilities: PureAbility, model: Prisma.ModelName) { +export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abilities: PureAbility, model: Prisma.ModelName, queryAllRuleRelations?: boolean) { const operationAbility = caslOperationDict[operation] accessibleBy(abilities, operationAbility.action)[model] @@ -49,7 +49,7 @@ export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abil } const result = operationAbility.includeSelectQuery - ? applyRuleRelationsQuery(args, abilities, operationAbility.action, model, creationTree) + ? applyRuleRelationsQuery(args, abilities, queryAllRuleRelations ? 'all' : operationAbility.action, model, creationTree) : { args, mask: undefined, creationTree } return result diff --git a/src/applyRuleRelationsQuery.ts b/src/applyRuleRelationsQuery.ts index 144131e..04a6623 100644 --- a/src/applyRuleRelationsQuery.ts +++ b/src/applyRuleRelationsQuery.ts @@ -136,7 +136,6 @@ export function applyRuleRelationsQuery(args: any, abilities: PureAbility rule.conditions).map((rule) => { return { ...rule, + action: action === 'all' ? action : rule.action, inverted: false } })) diff --git a/src/filterQueryResults.ts b/src/filterQueryResults.ts index a9297a0..e5dc116 100644 --- a/src/filterQueryResults.ts +++ b/src/filterQueryResults.ts @@ -3,8 +3,9 @@ import { PrismaQuery } from "@casl/prisma"; import { Prisma } from "@prisma/client"; import { CreationTree } from "./convertCreationTreeToSelect"; import { getPermittedFields, getSubject, relationFieldsByModel } from "./helpers"; +import { storePermissions } from "./storePermissions"; -export function filterQueryResults(result: any, mask: any, creationTree: CreationTree | undefined, abilities: PureAbility, model: string) { +export function filterQueryResults(result: any, mask: any, creationTree: CreationTree | undefined, abilities: PureAbility, model: string, permissionField?: string) { if (typeof result === 'number') { return result } @@ -27,7 +28,7 @@ export function filterQueryResults(result: any, mask: any, creationTree: Creatio const permittedFields = getPermittedFields(abilities, 'read', model, entry) let hasKeys = false - Object.keys(entry).forEach((field) => { + Object.keys(entry).filter((field) => field !== permissionField).forEach((field) => { const relationField = relationFieldsByModel[model][field] if (relationField) { const nestedCreationTree = creationTree && field in creationTree.children ? creationTree.children[field] : undefined @@ -48,9 +49,10 @@ export function filterQueryResults(result: any, mask: any, creationTree: Creatio return hasKeys && Object.keys(entry).length > 0 ? entry : null } - if (Array.isArray(result)) { - return result.map((entry) => filterPermittedFields(entry)).filter((x) => x) + const permissionResult = storePermissions(result, abilities, model, permissionField) + if (Array.isArray(permissionResult)) { + return permissionResult.map((entry) => filterPermittedFields(entry)).filter((x) => x) } else { - return filterPermittedFields(result) + return filterPermittedFields(permissionResult) } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5d3f642..152101c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ 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 function useCaslAbilities(getAbilityFactory: () => AbilityBuilder>) { +export function useCaslAbilities(getAbilityFactory: () => AbilityBuilder>, permissionField?: string) { return Prisma.defineExtension((client) => { @@ -76,7 +76,7 @@ export function useCaslAbilities(getAbilityFactory: () => AbilityBuilder AbilityBuilder AbilityBuilder, model: string, prop?: string) { + if (prop === undefined) { + return result + } + const actions = ['create', 'read', 'update', 'delete'] as const + const storeProp = (entry: any) => { + entry[prop] = [] + actions.forEach((action) => { + if (abilities.can(action, getSubject(model, entry))) { + entry[prop].push(action) + } + }) + return entry + } + if (Array.isArray(result)) { + const res = result.map(storeProp) + return res + } else { + return storeProp(result) + } +} \ No newline at end of file diff --git a/test/extension.test.ts b/test/extension.test.ts index 944cc95..8024801 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -1584,6 +1584,67 @@ describe('prisma extension casl', () => { }) }) + describe('store permissions', () => { + it('has permissions on custom prop on chained queries', async () => { + function builderFactory() { + const builder = abilityBuilder() + const { can, cannot } = builder + + can('create', 'User') + can('read', 'User') + can('delete', 'User', { + posts: { + some: { + id: 0 + } + } + }) + can('update', 'User', { + posts: { + some: { + id: 0 + } + } + }) + return builder + } + const client = seedClient.$extends( + useCaslAbilities(builderFactory, 'casl') + ) + const result = await client.user.findMany() + expect(result).toEqual([{ email: '0', id: 0, 'casl': ['create', 'read', 'update', 'delete'] }, { email: '1', id: 1, 'casl': ['create', 'read'] }]) + }) + it('has permissions on custom prop on chained queries', async () => { + function builderFactory() { + const builder = abilityBuilder() + const { can, cannot } = builder + + can('create', 'User') + can('read', 'Post') + can('read', 'User') + can('delete', 'User', { + posts: { + some: { + id: 0 + } + } + }) + can('update', 'User', { + posts: { + some: { + id: 0 + } + } + }) + return builder + } + const client = seedClient.$extends( + useCaslAbilities(builderFactory, 'casl') + ) + const result = await client.post.findUnique({ where: { id: 0 } }).author() + expect(result).toEqual({ email: '0', id: 0, casl: ['create', 'read'] }) + }) + }) }) afterAll(async () => {