Skip to content

Commit

Permalink
feat: ✨ add possibility to store simplified crud rights on model
Browse files Browse the repository at this point in the history
  • Loading branch information
dennemark committed Sep 24, 2024
1 parent 4b159dc commit 7130a33
Show file tree
Hide file tree
Showing 7 changed files with 106 additions and 15 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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'] }
*/
```

Expand Down
4 changes: 2 additions & 2 deletions src/applyCaslToQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbilityTuple, PrismaQuery>, model: Prisma.ModelName) {
export function applyCaslToQuery(operation: PrismaCaslOperation, args: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, model: Prisma.ModelName, queryAllRuleRelations?: boolean) {
const operationAbility = caslOperationDict[operation]

accessibleBy(abilities, operationAbility.action)[model]
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/applyRuleRelationsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ export function applyRuleRelationsQuery(args: any, abilities: PureAbility<Abilit
if ('include' in result.args && Object.keys(result.args.include!).length === 0) {
delete result.args.include
}

return { ...result, creationTree }
}

Expand All @@ -157,9 +156,11 @@ function getNestedQueryRelations(args: any, abilities: PureAbility<AbilityTuple,
// if a rule is inverted and if a can rule exists without condition
// we therefore create fake ability here
// to get our rule relations query
// furthermore if we query for action = 'all' we rename rule action to 'all'
const ability = createPrismaAbility(abilities.rules.filter((rule) => rule.conditions).map((rule) => {
return {
...rule,
action: action === 'all' ? action : rule.action,
inverted: false
}
}))
Expand Down
12 changes: 7 additions & 5 deletions src/filterQueryResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbilityTuple, PrismaQuery>, model: string) {
export function filterQueryResults(result: any, mask: any, creationTree: CreationTree | undefined, abilities: PureAbility<AbilityTuple, PrismaQuery>, model: string, permissionField?: string) {
if (typeof result === 'number') {
return result
}
Expand All @@ -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
Expand All @@ -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)
}
}
9 changes: 4 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PureAbility<AbilityTuple, PrismaQuery>>) {
export function useCaslAbilities(getAbilityFactory: () => AbilityBuilder<PureAbility<AbilityTuple, PrismaQuery>>, permissionField?: string) {


return Prisma.defineExtension((client) => {
Expand Down Expand Up @@ -76,7 +76,7 @@ export function useCaslAbilities(getAbilityFactory: () => AbilityBuilder<PureAbi
perf?.mark('prisma-casl-extension-1')


const caslQuery = applyCaslToQuery(operation, args, abilities, model)
const caslQuery = applyCaslToQuery(operation, args, abilities, model, permissionField ? true : false)


perf?.mark('prisma-casl-extension-2')
Expand All @@ -87,8 +87,7 @@ export function useCaslAbilities(getAbilityFactory: () => AbilityBuilder<PureAbi

perf?.mark('prisma-casl-extension-3')


const res = filterQueryResults(result, caslQuery.mask, caslQuery.creationTree, abilities, getFluentModel(model, rest))
const filteredResult = filterQueryResults(result, caslQuery.mask, caslQuery.creationTree, abilities, getFluentModel(model, rest), permissionField)

if (perf) {
perf.mark('prisma-casl-extension-4')
Expand All @@ -104,7 +103,7 @@ export function useCaslAbilities(getAbilityFactory: () => AbilityBuilder<PureAbi
)
}

return operation === 'createMany' ? { count: res.length } : res
return operation === 'createMany' ? { count: filteredResult.length } : filteredResult
}
const operationAbility = caslOperationDict[operation as PrismaCaslOperation]
/**
Expand Down
25 changes: 25 additions & 0 deletions src/storePermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AbilityTuple, PureAbility } from "@casl/ability";
import { PrismaQuery } from "@casl/prisma";
import { getSubject } from "./helpers";

export function storePermissions(result: any, abilities: PureAbility<AbilityTuple, PrismaQuery>, 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)
}
}
61 changes: 61 additions & 0 deletions test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down

0 comments on commit 7130a33

Please sign in to comment.