Skip to content

Commit

Permalink
Adds {list}.hooks.validate.[create|update|delete] hooks (#9056)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcousens committed Mar 6, 2024
1 parent d3a3a0e commit 3d20b94
Show file tree
Hide file tree
Showing 17 changed files with 1,361 additions and 1,043 deletions.
5 changes: 5 additions & 0 deletions .changeset/deprecate-validate-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
----
'@keystone-6/core': minor
----

Adds `{list}.hooks.validate.[create|update|delete]` hooks, deprecates `validateInput` and `validateDelete`
181 changes: 169 additions & 12 deletions packages/core/src/lib/core/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
} from '../../types'
import { coerceAndValidateForGraphQLInput } from '../coerceAndValidateForGraphQLInput'
import { allowAll } from '../../access'
import { accessReturnError, extensionError } from './graphql-errors'
import {
accessDeniedError,
accessReturnError,
extensionError
} from './graphql-errors'
import { type InitialisedList } from './initialise-lists'
import { type InputFilter } from './where-inputs'

Expand All @@ -41,20 +45,19 @@ export async function getOperationFieldAccess (
list: InitialisedList,
fieldKey: string,
context: KeystoneContext,
operation: 'read' | 'create' | 'update'
operation: 'read'
) {
const { listKey } = list
const access = list.fields[fieldKey].access[operation]
let result
try {
result = await access({
result = await list.fields[fieldKey].access.read({
operation: 'read',
session: context.session,
listKey,
fieldKey,
context,
item,
} as never) // TODO: FIXME
})
} catch (error: any) {
throw extensionError('Access control', [
{ error, tag: `${list.listKey}.${fieldKey}.access.${operation}` },
Expand All @@ -76,15 +79,39 @@ export async function getOperationAccess (
operation: 'query' | 'create' | 'update' | 'delete'
) {
const { listKey } = list
const access = list.access.operation[operation]
let result
try {
result = await access({
operation,
session: context.session,
listKey,
context
} as never) // TODO: FIXME
if (operation === 'query') {
result = await list.access.operation.query({
operation,
session: context.session,
listKey,
context
})
} else if (operation === 'create') {
result = await list.access.operation.create({
operation,
session: context.session,
listKey,
context
})

} else if (operation === 'update') {
result = await list.access.operation.update({
operation,
session: context.session,
listKey,
context
})

} else if (operation === 'delete') {
result = await list.access.operation.delete({
operation,
session: context.session,
listKey,
context
})
}
} catch (error: any) {
throw extensionError('Access control', [
{ error, tag: `${listKey}.access.operation.${operation}` },
Expand Down Expand Up @@ -145,6 +172,136 @@ export async function getAccessFilters (
}
}

export async function enforceListLevelAccessControl (
context: KeystoneContext,
operation: 'create' | 'update' | 'delete',
list: InitialisedList,
inputData: Record<string, unknown>,
item: BaseItem | undefined,
) {
let accepted: unknown // should be boolean, but dont trust, it might accidentally be a filter
try {
// apply access.item.* controls
if (operation === 'create') {
const itemAccessControl = list.access.item[operation]
accepted = await itemAccessControl({
operation,
session: context.session,
listKey: list.listKey,
context,
inputData,
})
} else if (operation === 'update' && item !== undefined) {
const itemAccessControl = list.access.item[operation]
accepted = await itemAccessControl({
operation,
session: context.session,
listKey: list.listKey,
context,
item,
inputData,
})
} else if (operation === 'delete' && item !== undefined) {
const itemAccessControl = list.access.item[operation]
accepted = await itemAccessControl({
operation,
session: context.session,
listKey: list.listKey,
context,
item,
})
}
} catch (error: any) {
throw extensionError('Access control', [
{ error, tag: `${list.listKey}.access.item.${operation}` },
])
}

// short circuit the safe path
if (accepted === true) return

if (typeof accepted !== 'boolean') {
throw accessReturnError([
{
tag: `${list.listKey}.access.item.${operation}`,
returned: typeof accepted,
},
])
}

throw accessDeniedError(cannotForItem(operation, list))
}

export async function enforceFieldLevelAccessControl (
context: KeystoneContext,
operation: 'create' | 'update',
list: InitialisedList,
inputData: Record<string, unknown>,
item: BaseItem | undefined,
) {
const nonBooleans: { tag: string, returned: string }[] = []
const fieldsDenied: string[] = []
const accessErrors: { error: Error, tag: string }[] = []

await Promise.allSettled(
Object.keys(inputData).map(async fieldKey => {
let accepted: unknown // should be boolean, but dont trust
try {
// apply fields.[fieldKey].access.* controls
if (operation === 'create') {
const fieldAccessControl = list.fields[fieldKey].access[operation]
accepted = await fieldAccessControl({
operation,
session: context.session,
listKey: list.listKey,
fieldKey,
context,
inputData: inputData as any, // FIXME
})
} else if (operation === 'update' && item !== undefined) {
const fieldAccessControl = list.fields[fieldKey].access[operation]
accepted = await fieldAccessControl({
operation,
session: context.session,
listKey: list.listKey,
fieldKey,
context,
item,
inputData,
})
}
} catch (error: any) {
accessErrors.push({ error, tag: `${list.listKey}.${fieldKey}.access.${operation}` })
return
}

// short circuit the safe path
if (accepted === true) return
fieldsDenied.push(fieldKey)

// wrong type?
if (typeof accepted !== 'boolean') {
nonBooleans.push({
tag: `${list.listKey}.${fieldKey}.access.${operation}`,
returned: typeof accepted,
})
}
})
)

if (nonBooleans.length) {
throw accessReturnError(nonBooleans)
}

if (accessErrors.length) {
throw extensionError('Access control', accessErrors)
}

if (fieldsDenied.length) {
throw accessDeniedError(cannotForItemFields(operation, list, fieldsDenied))
}
}

export type ResolvedFieldAccessControl = {
create: IndividualFieldAccessControl<FieldCreateItemAccessArgs<BaseListTypeInfo>>
read: IndividualFieldAccessControl<FieldReadItemAccessArgs<BaseListTypeInfo>>
Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/lib/core/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { extensionError, validationFailureError } from './graphql-errors'
import { type InitialisedList } from './initialise-lists'

export async function validate ({
list,
hookArgs,
}: {
list: InitialisedList
hookArgs: Omit<
Parameters<InitialisedList['hooks']['validate']['create' | 'update' | 'delete']>[0],
'addValidationError'
>
}) {
const messages: string[] = []
const fieldsErrors: { error: Error, tag: string }[] = []
const { operation } = hookArgs

// field validation hooks
await Promise.all(
Object.entries(list.fields).map(async ([fieldKey, field]) => {
const addValidationError = (msg: string) => void messages.push(`${list.listKey}.${fieldKey}: ${msg}`)
const hook = field.hooks.validate[operation]

try {
await hook({ ...hookArgs, addValidationError, fieldKey } as never) // TODO: FIXME
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateInput` })
}
})
)

if (fieldsErrors.length) {
throw extensionError('validateInput', fieldsErrors)
}

// list validation hooks
{
const addValidationError = (msg: string) => void messages.push(`${list.listKey}: ${msg}`)
const hook = list.hooks.validate[operation]

try {
await hook({ ...hookArgs, addValidationError } as never) // TODO: FIXME
} catch (error: any) {
throw extensionError('validateInput', [
{ error, tag: `${list.listKey}.hooks.validateInput` }
])
}

if (messages.length) {
throw validationFailureError(messages)
}
}
}

export async function runSideEffectOnlyHook<
HookName extends 'beforeOperation' | 'afterOperation',
Args extends Parameters<
NonNullable<InitialisedList['hooks'][HookName]['create' | 'update' | 'delete']>
>[0]
> (list: InitialisedList, hookName: HookName, args: Args) {
const { operation } = args

let shouldRunFieldLevelHook: (fieldKey: string) => boolean
if (operation === 'delete') {
// always run field hooks for delete operations
shouldRunFieldLevelHook = () => true
} else {
// only run field hooks on if the field was specified in the
// original input for create and update operations.
const inputDataKeys = new Set(Object.keys(args.inputData))
shouldRunFieldLevelHook = fieldKey => inputDataKeys.has(fieldKey)
}

// field hooks
const fieldsErrors: { error: Error, tag: string }[] = []
await Promise.all(
Object.entries(list.fields).map(async ([fieldKey, field]) => {
if (shouldRunFieldLevelHook(fieldKey)) {
try {
await field.hooks[hookName][operation]({ fieldKey, ...args } as any) // TODO: FIXME any
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` })
}
}
})
)

if (fieldsErrors.length) {
throw extensionError(hookName, fieldsErrors)
}

// list hooks
try {
await list.hooks[hookName][operation](args as any) // TODO: FIXME any
} catch (error: any) {
throw extensionError(hookName, [{ error, tag: `${list.listKey}.hooks.${hookName}` }])
}
}

0 comments on commit 3d20b94

Please sign in to comment.