prisma-extension-casl
Version:
Enforce casl abilities on prisma client
226 lines (211 loc) • 8.71 kB
text/typescript
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 type { DMMF } from '@prisma/generator-helper'
type DefaultCaslAction = "create" | "read" | "update" | "delete"
export type PrismaExtensionCaslOptions = {
/**
* will add a field on each returned prisma result that stores allowed actions on result (not nested)
* so instead of { id: 0 } it would return { id: 0, [permissionField]: ['create', 'read', 'update', 'delete'] }
*
* to return other actions, please use addPermissionActions
*/
permissionField?: string
/**
* adds additional permission actions to ['create', 'read', 'update', 'delete']
* that should be returned if permissionField is used.
*/
addPermissionActions?: string[]
/** uses transaction to allow using client queries before actual query, if fails, whole query will be rolled back */
beforeQuery?: (tx: Prisma.TransactionClient) => Promise<void>,
/** uses transaction to allow using client queries after actual query, if fails, whole query will be rolled back */
afterQuery?: (tx: Prisma.TransactionClient) => Promise<void>,
/** max wait for batch transaction - default 30000 */
txMaxWait?: number
/** timeout for batch transaction - default 30000 */
txTimeout?: number
}
export type PrismaCaslOperation =
'create' |
'createMany' |
'createManyAndReturn' |
'upsert' |
'findFirst' |
'findFirstOrThrow' |
'findMany' |
'findUnique' |
'findUniqueOrThrow' |
'aggregate' |
'count' |
'groupBy' |
'update' |
'updateMany' |
'updateManyAndReturn' |
'delete' |
'deleteMany'
export const caslOperationDict: Record<
PrismaCaslOperation,
{
action: DefaultCaslAction
dataQuery: boolean
whereQuery: boolean
includeSelectQuery: boolean
}
> = {
create: { action: 'create', dataQuery: true, whereQuery: false, includeSelectQuery: true },
createMany: { action: 'create', dataQuery: true, whereQuery: false, includeSelectQuery: true },
createManyAndReturn: { action: 'create', dataQuery: true, whereQuery: false, includeSelectQuery: true },
upsert: { action: 'create', dataQuery: true, whereQuery: true, includeSelectQuery: true },
findFirst: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
findFirstOrThrow: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
findMany: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
findUnique: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
findUniqueOrThrow: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: true },
aggregate: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: false },
count: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: false },
groupBy: { action: 'read', dataQuery: false, whereQuery: true, includeSelectQuery: false },
update: { action: 'update', dataQuery: true, whereQuery: true, includeSelectQuery: true },
updateMany: { action: 'update', dataQuery: true, whereQuery: true, includeSelectQuery: false },
updateManyAndReturn: { action: 'update', dataQuery: true, whereQuery: true, includeSelectQuery: false },
delete: { action: 'delete', dataQuery: false, whereQuery: true, includeSelectQuery: true },
deleteMany: { action: 'delete', dataQuery: false, whereQuery: true, includeSelectQuery: false },
} as const
export const caslNestedOperationDict: Record<string, 'create' | 'update' | 'read' | 'delete'> = {
upsert: 'create',
connect: 'update',
connectOrCreate: 'create',
create: 'create',
createMany: 'create',
update: 'update',
updateMany: 'update',
delete: 'delete',
deleteMany: 'delete',
disconnect: 'update',
set: 'update'
}
export const relationFieldsByModel = Object.fromEntries(Prisma.dmmf.datamodel.models.map((model: DMMF.Model) => {
const relationFields = Object.fromEntries(model.fields
.filter((field) => field && field.kind === 'object' && field.relationName)
.map((field) => ([field.name, field])))
return [model.name, relationFields]
}))
export const propertyFieldsByModel = Object.fromEntries(Prisma.dmmf.datamodel.models.map((model: DMMF.Model) => {
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))
return [field.name, relation?.name]
}))
return [model.name, propertyFields]
}))
export function pick<T extends Record<string, K>, 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<T, K>);
}
/**
* goes through all permitted fields of a model
*
* - if there area only rules with fields, permittedFields are at least empty
*
* - if a rule has fields And conditions, it's fields are only permitted
* if the query overlaps with the conditions
*
* - if permittedFields are empty, but there is a rule without fields,
* the result is undefined. allowing us to query for all fields
*
* @param abilities
* @param action
* @param model
* @param obj
* @returns
*/
export function getPermittedFields(
abilities: PureAbility<AbilityTuple, PrismaQuery>,
action: string,
model: string,
obj?: any
) {
const modelFields = Object.keys(propertyFieldsByModel[model])
const permittedFields = permittedFieldsOf(abilities, action, obj ? getSubject(model, obj) : model, {
fieldsFrom: rule => {
return rule.fields || modelFields;
}
})
return permittedFields
}
/**
* helper function to get subject for a model
* @param model prisma model
* @param obj
* @returns
*/
export function getSubject(model: string, obj: any) {
const modelFields = Object.keys(propertyFieldsByModel[model])
const subjectFields = [...modelFields, ...Object.keys(relationFieldsByModel[model])]
return subject(model, pick(obj, subjectFields))
}
export function getFluentField(data: any) {
const dataPath = data?.__internalParams?.dataPath as string[]
if (dataPath?.length > 0) {
return dataPath[dataPath.length - 1]
} else {
return undefined
}
}
/**
* 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 startRelation = {
fluentModel: startModel,
fluentRelationField: undefined as DMMF.Field | undefined,
fluentRelationModel: undefined as string | undefined
}
const dataPath = data?.__internalParams?.dataPath as string[]
if (dataPath?.length > 0) {
return dataPath.filter((x) => x !== 'select').reduce((acc, x) => {
acc.fluentRelationField = relationFieldsByModel[acc.fluentModel][x]
acc.fluentModel = acc.fluentRelationField.type
acc.fluentRelationModel = x
return acc
}, startRelation)
} else {
return startRelation
}
}
export function isSubset(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 === "object" && typeof obj2 === "object") {
if (Array.isArray(obj1) && Array.isArray(obj2)) {
for (const item1 of obj1) {
let found = false;
for (const item2 of obj2) {
if (isSubset(item1, item2)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
} else {
for (const key in obj1) {
if (!(key in obj2) || !isSubset(obj1[key], obj2[key])) {
return false;
}
}
return true;
}
}
return false;
}