feathers-casl
Version:
Add access control with CASL to your feathers application.
273 lines (238 loc) • 7.14 kB
text/typescript
import _get from 'lodash/get.js'
import _set from 'lodash/set.js'
import { Forbidden } from '@feathersjs/errors'
import {
getFieldsForConditions,
getAvailableFields,
} from '../../utils/index.js'
import { makeDefaultBaseOptions } from '../common.js'
import { getResultIsArray } from 'feathers-utils'
import { isMulti, markHookForSkip } from '@fratzinger/feathers-utils'
import type { AnyAbility, ForcedSubject } from '@casl/ability'
import type { Application, HookContext, Params } from '@feathersjs/feathers'
import type {
Adapter,
AuthorizeHookOptions,
AuthorizeHookOptionsExclusive,
AvailableFieldsOption,
HookBaseOptions,
InitOptions,
Path,
ThrowUnlessCanOptions,
} from '../../types.js'
import type { Promisable } from 'type-fest'
import { getMethodName } from '../../utils/getMethodName.js'
declare module '@feathersjs/feathers' {
interface Params {
ability?:
| AnyAbility
| ((context: HookContext) => AnyAbility | Promise<AnyAbility>)
}
}
export const HOOKNAME = 'authorize'
export const makeOptions = <A extends Application = Application>(
app: A,
options?: Partial<AuthorizeHookOptions>,
): AuthorizeHookOptions => {
options = options || {}
return Object.assign(
makeDefaultBaseOptions(),
defaultOptions,
getAppOptions(app),
options,
) as unknown as AuthorizeHookOptions
}
const defaultOptions = {
adapter: undefined,
availableFields: (context: HookContext): string[] | undefined => {
const availableFields: AvailableFieldsOption =
context.service.options?.casl?.availableFields
return getAvailableFields(context, { availableFields })
},
usePatchData: false,
useUpdateData: false,
} satisfies Partial<AuthorizeHookOptionsExclusive<HookContext>>
export const makeDefaultOptions = (
options?: Partial<AuthorizeHookOptions>,
): AuthorizeHookOptions => {
return Object.assign(
makeDefaultBaseOptions(),
defaultOptions,
options,
) as unknown as AuthorizeHookOptions
}
const getAppOptions = (
app: Application,
): AuthorizeHookOptions | Record<string, never> => {
const caslOptions: InitOptions = app?.get('casl')
return caslOptions && caslOptions.authorizeHook
? caslOptions.authorizeHook
: {}
}
export const getAdapter = (
app: Application,
options: Pick<AuthorizeHookOptions, 'adapter'>,
): Adapter => {
if (options.adapter) {
return options.adapter
}
const caslAppOptions = app?.get('casl') as InitOptions
if (caslAppOptions?.defaultAdapter) {
return caslAppOptions.defaultAdapter
}
return '@feathersjs/memory'
}
export const getAbility = (
context: HookContext,
options?: Pick<
HookBaseOptions,
'ability' | 'checkAbilityForInternal' | 'method'
>,
): Promise<AnyAbility | undefined> => {
const method = getMethodName(context, options)
// if params.ability is set, return it over options.ability
if (context?.params?.ability) {
if (typeof context.params.ability === 'function') {
const ability = context.params.ability(context)
return Promise.resolve(ability)
} else {
return Promise.resolve(context.params.ability)
}
}
const persistedAbility = getPersistedConfig(context, 'ability')
if (persistedAbility) {
if (typeof persistedAbility === 'function') {
const ability = persistedAbility(context)
return Promise.resolve(ability)
} else {
return Promise.resolve(persistedAbility)
}
}
if (!options?.checkAbilityForInternal && !context.params?.provider) {
return Promise.resolve(undefined)
}
if (options?.ability) {
if (typeof options.ability === 'function') {
const ability = options.ability(context)
return Promise.resolve(ability)
} else {
return Promise.resolve(options.ability)
}
}
throw new Forbidden(`You're not allowed to ${method} on '${context.path}'`)
}
export const throwUnlessCan = <T extends ForcedSubject<string>>(
ability: AnyAbility,
method: string,
resource: string | T,
modelName: string,
options: Partial<ThrowUnlessCanOptions>,
): boolean => {
if (ability.cannot(method, resource)) {
if (options.actionOnForbidden) options.actionOnForbidden()
/* v8 ignore start */
if (options.debug) {
console.error(
'Feathers-CASL: throwUnlessCan - permission denied',
method,
modelName,
resource,
ability.relevantRuleFor(method, resource),
)
}
/* v8 ignore stop */
if (!options.skipThrow) {
throw new Forbidden(`You are not allowed to ${method} ${modelName}`)
}
return false
}
return true
}
export const refetchItems = async (
context: HookContext,
params?: Params,
): Promise<unknown[] | undefined> => {
if (!context.result) {
return
}
const { result: items } = getResultIsArray(context)
if (!items) {
return
}
const idField = context.service.options?.id
const ids = items.map((item) => item[idField])
params = Object.assign({}, params, { paginate: false })
markHookForSkip(HOOKNAME, 'all', { params } as any)
delete params.ability
const query = Object.assign({}, params.query, { [idField]: { $in: ids } })
params = Object.assign({}, params, { query })
return await context.service.find(params)
}
export const getConditionalSelect = (
$select: string[] | undefined,
ability: AnyAbility,
method: string,
modelName: string,
): undefined | string[] => {
if (!$select?.length) {
return undefined
}
const fields = getFieldsForConditions(ability, method, modelName)
if (!fields.length) {
return undefined
}
const fieldsToAdd = fields.filter((field) => !$select.includes(field))
if (!fieldsToAdd.length) {
return undefined
}
return [...$select, ...fieldsToAdd]
}
export const checkMulti = (
context: HookContext,
ability: AnyAbility,
modelName: string,
options?: Pick<AuthorizeHookOptions, 'actionOnForbidden' | 'method'>,
): boolean => {
const method = getMethodName(context, options)
const currentIsMulti = isMulti(context)
if (!currentIsMulti) {
return true
}
if (
(method === 'find' && ability.can(method, modelName)) ||
ability.can(`${method}-multi`, modelName)
) {
return true
}
if (options?.actionOnForbidden) options.actionOnForbidden()
throw new Forbidden(`You're not allowed to multi-${method} ${modelName}`)
}
export const setPersistedConfig = (
context: HookContext,
key: Path,
val: unknown,
): HookContext => {
return _set(context, `params.casl.${key}`, val)
}
export function getPersistedConfig(
context: HookContext,
key: 'ability',
):
| AnyAbility
| ((context: HookContext) => Promisable<AnyAbility | undefined>)
| undefined
export function getPersistedConfig(
context: HookContext,
key: 'skipRestrictingRead.conditions',
): boolean
export function getPersistedConfig(
context: HookContext,
key: 'skipRestrictingRead.fields',
): boolean
export function getPersistedConfig(
context: HookContext,
key: 'madeBasicCheck',
): boolean
export function getPersistedConfig(context: HookContext, key: Path): any {
return _get(context, `params.casl.${key}`)
}