diff --git a/packages/casl-mongoose/spec/accessibleBy.spec.ts b/packages/casl-mongoose/spec/accessibleBy.spec.ts index bc9fe1514..ae06f2c0c 100644 --- a/packages/casl-mongoose/spec/accessibleBy.spec.ts +++ b/packages/casl-mongoose/spec/accessibleBy.spec.ts @@ -1,6 +1,5 @@ import { defineAbility } from "@casl/ability" import { accessibleBy } from "../src" -import { testConversionToMongoQuery } from "./mongo_query.spec" declare module '../src' { interface RecordTypes { @@ -14,7 +13,7 @@ describe('accessibleBy', () => { can('read', 'Post') }) - const query = accessibleBy(ability, 'update').Post + const query = accessibleBy(ability, 'update').ofType('Post') expect(query).toEqual({ $expr: { $eq: [0, 1] } }) }) @@ -25,13 +24,123 @@ describe('accessibleBy', () => { cannot('update', 'Post') }) - const query = accessibleBy(ability, 'update').Post + const query = accessibleBy(ability, 'update').ofType('Post') expect(query).toEqual({ $expr: { $eq: [0, 1] } }) }) describe('it behaves like `toMongoQuery` when converting rules', () => { - testConversionToMongoQuery((ability, subjectType, action) => - accessibleBy(ability, action)[subjectType]) + it('accepts ability action as third argument', () => { + const ability = defineAbility((can) => { + can('update', 'Post', { _id: 'mega' }) + }) + const query = accessibleBy(ability, 'update').ofType('Post') + + expect(query).toEqual({ + $or: [{ _id: 'mega' }] + }) + }) + + it('OR-es conditions for regular rules and AND-es for inverted ones', () => { + const ability = defineAbility((can, cannot) => { + can('read', 'Post', { _id: 'mega' }) + can('read', 'Post', { state: 'draft' }) + cannot('read', 'Post', { private: true }) + cannot('read', 'Post', { state: 'archived' }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ + $or: [ + { state: 'draft' }, + { _id: 'mega' } + ], + $and: [ + { $nor: [{ state: 'archived' }] }, + { $nor: [{ private: true }] } + ] + }) + }) + + describe('can find records where property', () => { + it('is present', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { isPublished: { $exists: true, $ne: null } }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ isPublished: { $exists: true, $ne: null } }] }) + }) + + it('is blank', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { isPublished: { $exists: false } }) + can('read', 'Post', { isPublished: null }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ + $or: [ + { isPublished: null }, + { isPublished: { $exists: false } } + ] + }) + }) + + it('is defined by `$in` criteria', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { state: { $in: ['draft', 'archived'] } }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ state: { $in: ['draft', 'archived'] } }] }) + }) + + it('is defined by `$all` criteria', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { state: { $all: ['draft', 'archived'] } }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ state: { $all: ['draft', 'archived'] } }] }) + }) + it('is defined by `$lt` and `$lte` criteria', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { views: { $lt: 10 } }) + can('read', 'Post', { views: { $lt: 5 } }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ views: { $lt: 5 } }, { views: { $lt: 10 } }] }) + }) + + it('is defined by `$gt` and `$gte` criteria', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { views: { $gt: 10 } }) + can('read', 'Post', { views: { $gte: 100 } }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ views: { $gte: 100 } }, { views: { $gt: 10 } }] }) + }) + + it('is defined by `$ne` criteria', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { creator: { $ne: 'me' } }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ creator: { $ne: 'me' } }] }) + }) + + it('is defined by dot notation fields', () => { + const ability = defineAbility((can) => { + can('read', 'Post', { 'comments.author': 'Ted' }) + }) + const query = accessibleBy(ability).ofType('Post') + + expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] }) + }) + }) }) }) diff --git a/packages/casl-mongoose/spec/accessible_records.spec.ts b/packages/casl-mongoose/spec/accessible_records.spec.ts index 1391f69d6..69827182e 100644 --- a/packages/casl-mongoose/spec/accessible_records.spec.ts +++ b/packages/casl-mongoose/spec/accessible_records.spec.ts @@ -1,6 +1,6 @@ -import { Ability, createMongoAbility, defineAbility, ForbiddenError, MongoAbility, SubjectType } from '@casl/ability' +import { Ability, defineAbility, SubjectType } from '@casl/ability' import mongoose from 'mongoose' -import { AccessibleRecordModel, accessibleRecordsPlugin, toMongoQuery } from '../src' +import { accessibleBy, AccessibleRecordModel, accessibleRecordsPlugin } from '../src' describe('Accessible Records Plugin', () => { interface Post { @@ -58,7 +58,7 @@ describe('Accessible Records Plugin', () => { const query = Post.accessibleBy(ability).getQuery() expect(query).toEqual({ - $and: [toMongoQuery(ability, 'Post')] + $and: [accessibleBy(ability).ofType('Post')] }) }) @@ -69,7 +69,7 @@ describe('Accessible Records Plugin', () => { expect(conditions.$and).toEqual([ ...existingConditions, - toMongoQuery(ability, 'Post') + accessibleBy(ability).ofType('Post') ]) }) @@ -107,98 +107,12 @@ describe('Accessible Records Plugin', () => { }) }) - describe('when ability disallow to perform an action', () => { - let query: mongoose.QueryWithHelpers - - beforeEach(() => { - query = Post.find().accessibleBy(ability, 'notAllowedAction') - }) - - it('throws `ForbiddenError` for collection request', async () => { - await query.exec() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` when `find` is called', async () => { - await query.find() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` for item request', async () => { - await query.findOne().exec() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` when `findOne` is called', async () => { - await query.findOne() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` for count request', async () => { - await query.count() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` for count request', async () => { - await query.count() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` for `countDocuments` request', async () => { - await query.countDocuments() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` for `estimatedDocumentCount` request', async () => { - await query.estimatedDocumentCount() - .then(() => fail('should not execute')) - .catch((error: any) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect(error.message).toMatch(/cannot execute/i) - }) - }) - - it('throws `ForbiddenError` with correct message when subjectType is a model', async () => { - const anotherAbility = createMongoAbility([ - { action: 'read', subject: Post } - ], { - detectSubjectType: o => o.constructor as SubjectType, - }) - - await Post.find().accessibleBy(anotherAbility, 'update') - .then(() => fail('should never be called')) - .catch((error: unknown) => { - expect(error).toBeInstanceOf(ForbiddenError) - expect((error as ForbiddenError).message).toBe('Cannot execute "update" on "Post"') - }) + it('returns always empty result query if ability disallow to perform action', () => { + const q = Post.find().accessibleBy(ability, 'notAllowedAction') + expect(q.getQuery()).toEqual({ + $and: [ + { $expr: { $eq: [0, 1] } } + ] }) }) }) diff --git a/packages/casl-mongoose/spec/mongo_query.spec.ts b/packages/casl-mongoose/spec/mongo_query.spec.ts deleted file mode 100644 index 8a33c54bb..000000000 --- a/packages/casl-mongoose/spec/mongo_query.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { defineAbility } from '@casl/ability' -import { toMongoQuery } from '../src' - -describe('toMongoQuery', () => { - testConversionToMongoQuery(toMongoQuery) - - it('returns `null` if there are no rules for specific subject/action', () => { - const ability = defineAbility((can) => { - can('update', 'Post') - }) - - const query = toMongoQuery(ability, 'Post', 'read') - - expect(query).toBe(null) - }) - - it('returns null if there is a rule that forbids previous one', () => { - const ability = defineAbility((can, cannot) => { - can('update', 'Post', { authorId: 1 }) - cannot('update', 'Post') - }) - - const query = toMongoQuery(ability, 'Post', 'update') - - expect(query).toBe(null) - }) -}) - -export function testConversionToMongoQuery(abilityToMongoQuery: typeof toMongoQuery) { - it('accepts ability action as third argument', () => { - const ability = defineAbility((can) => { - can('update', 'Post', { _id: 'mega' }) - }) - const query = abilityToMongoQuery(ability, 'Post', 'update') - - expect(query).toEqual({ - $or: [{ _id: 'mega' }] - }) - }) - - it('OR-es conditions for regular rules and AND-es for inverted ones', () => { - const ability = defineAbility((can, cannot) => { - can('read', 'Post', { _id: 'mega' }) - can('read', 'Post', { state: 'draft' }) - cannot('read', 'Post', { private: true }) - cannot('read', 'Post', { state: 'archived' }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ - $or: [ - { state: 'draft' }, - { _id: 'mega' } - ], - $and: [ - { $nor: [{ state: 'archived' }] }, - { $nor: [{ private: true }] } - ] - }) - }) - - describe('can find records where property', () => { - it('is present', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { isPublished: { $exists: true, $ne: null } }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ isPublished: { $exists: true, $ne: null } }] }) - }) - - it('is blank', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { isPublished: { $exists: false } }) - can('read', 'Post', { isPublished: null }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ - $or: [ - { isPublished: null }, - { isPublished: { $exists: false } } - ] - }) - }) - - it('is defined by `$in` criteria', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { state: { $in: ['draft', 'archived'] } }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ state: { $in: ['draft', 'archived'] } }] }) - }) - - it('is defined by `$all` criteria', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { state: { $all: ['draft', 'archived'] } }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ state: { $all: ['draft', 'archived'] } }] }) - }) - it('is defined by `$lt` and `$lte` criteria', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { views: { $lt: 10 } }) - can('read', 'Post', { views: { $lt: 5 } }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ views: { $lt: 5 } }, { views: { $lt: 10 } }] }) - }) - - it('is defined by `$gt` and `$gte` criteria', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { views: { $gt: 10 } }) - can('read', 'Post', { views: { $gte: 100 } }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ views: { $gte: 100 } }, { views: { $gt: 10 } }] }) - }) - - it('is defined by `$ne` criteria', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { creator: { $ne: 'me' } }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ creator: { $ne: 'me' } }] }) - }) - - it('is defined by dot notation fields', () => { - const ability = defineAbility((can) => { - can('read', 'Post', { 'comments.author': 'Ted' }) - }) - const query = abilityToMongoQuery(ability, 'Post') - - expect(query).toEqual({ $or: [{ 'comments.author': 'Ted' }] }) - }) - }) -} diff --git a/packages/casl-mongoose/src/accessibleBy.ts b/packages/casl-mongoose/src/accessibleBy.ts new file mode 100644 index 000000000..8115d3cc4 --- /dev/null +++ b/packages/casl-mongoose/src/accessibleBy.ts @@ -0,0 +1,34 @@ +import { AnyMongoAbility, Generics, SubjectType } from '@casl/ability'; +import { ToAbilityTypes } from '@casl/ability/dist/types/types'; +import { rulesToQuery } from '@casl/ability/extra'; + +function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) { + const conditions = rule.conditions!; + return rule.inverted ? { $nor: [conditions] } : conditions; +} + +export const EMPTY_RESULT_QUERY = { $expr: { $eq: [0, 1] } }; +export class AccessibleRecords { + constructor( + private readonly _ability: AnyMongoAbility, + private readonly _action: string + ) {} + + /** + * In case action is not allowed, it returns `{ $expr: { $eq: [0, 1] } }` + */ + ofType(subjectType: T): Record { + const query = rulesToQuery(this._ability, this._action, subjectType, convertToMongoQuery); + return query === null ? EMPTY_RESULT_QUERY : query as Record; + } +} + +/** + * Returns accessible records Mongo query per record type (i.e., entity type) based on provided Ability and action. + */ +export function accessibleBy( + ability: T, + action: Parameters[0] = 'read' +): AccessibleRecords['abilities']>[1]> { + return new AccessibleRecords(ability, action); +} diff --git a/packages/casl-mongoose/src/accessibleFieldsBy.ts b/packages/casl-mongoose/src/accessibleFieldsBy.ts new file mode 100644 index 000000000..acc075b45 --- /dev/null +++ b/packages/casl-mongoose/src/accessibleFieldsBy.ts @@ -0,0 +1,49 @@ +import { AnyAbility, AnyMongoAbility, Generics, RuleOf, Subject, SubjectType } from "@casl/ability"; +import { GetRuleFields, permittedFieldsOf } from "@casl/ability/extra"; + +type GetSubjectTypeAllFieldsExtractor = (subjectType: SubjectType) => string[]; +let getSubjectTypeAllFieldsExtractor: GetSubjectTypeAllFieldsExtractor = (type) => { + if (typeof type === 'string') throw new Error( + `Cannot determine all fields for subject type ${type}. Please provide a custom field extractor` + ); + return 'schema' in type ? (type.schema as any).paths : []; +}; +export function provideSubjectTypeAllFieldsExtractor(fn: GetSubjectTypeAllFieldsExtractor): void { + getSubjectTypeAllFieldsExtractor = fn; +} + +export class AccessibleFields { + constructor( + private readonly _ability: AnyMongoAbility, + private readonly _action: string + ) {} + + /** + * Returns accessible fields for Model type + */ + ofType(subjectType: Extract): string[] { + return permittedFieldsOf(this._ability, this._action, subjectType, { + fieldsFrom: getRuleFields(subjectType) + }); + } + + /** + * Returns accessible fields for particular document + */ + of(subject: Exclude): string[] { + return permittedFieldsOf(this._ability, this._action, subject, { + fieldsFrom: getRuleFields(this._ability.detectSubjectType(subject)) + }); + } +} + +export function accessibleFieldsBy( + ability: T, + action: Parameters[0] = 'read' +): AccessibleFields['abilities'], unknown[]>[1]> { + return new AccessibleFields(ability, action); +} + +function getRuleFields(type: SubjectType): GetRuleFields> { + return (rule) => (rule.fields || getSubjectTypeAllFieldsExtractor(type)); +} diff --git a/packages/casl-mongoose/src/index.ts b/packages/casl-mongoose/src/index.ts index b3350fd51..e682ad305 100644 --- a/packages/casl-mongoose/src/index.ts +++ b/packages/casl-mongoose/src/index.ts @@ -1,5 +1,5 @@ -import { AccessibleFieldDocumentMethods, AccessibleFieldsModel } from './accessible_fields'; -import { AccessibleRecordModel, AccessibleRecordQueryHelpers } from './accessible_records'; +import { AccessibleFieldDocumentMethods, AccessibleFieldsModel } from './plugins/accessible_fields'; +import { AccessibleRecordModel, AccessibleRecordQueryHelpers } from './plugins/accessible_records'; export interface AccessibleModel< T, @@ -17,14 +17,17 @@ export interface AccessibleModel< >, TMethods, TVirtuals> {} -export { accessibleRecordsPlugin } from './accessible_records'; -export type { AccessibleRecordModel } from './accessible_records'; -export { getSchemaPaths, accessibleFieldsPlugin } from './accessible_fields'; +export { accessibleRecordsPlugin } from './plugins/accessible_records'; +export type { AccessibleRecordModel } from './plugins/accessible_records'; +export { getSchemaPaths, accessibleFieldsPlugin } from './plugins/accessible_fields'; export type { AccessibleFieldsModel, AccessibleFieldsDocument, AccessibleFieldsOptions -} from './accessible_fields'; +} from './plugins/accessible_fields'; -export { toMongoQuery, accessibleBy } from './mongo'; -export type { RecordTypes } from './mongo'; +export { accessibleBy } from './accessibleBy'; +export type { AccessibleRecords } from './accessibleBy'; + +export { accessibleFieldsBy, provideSubjectTypeAllFieldsExtractor } from './accessibleFieldsBy'; +export type { AccessibleFields } from './accessibleFieldsBy'; diff --git a/packages/casl-mongoose/src/mongo.ts b/packages/casl-mongoose/src/mongo.ts deleted file mode 100644 index a0b5a1413..000000000 --- a/packages/casl-mongoose/src/mongo.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AnyMongoAbility } from '@casl/ability'; -import { AbilityQuery, rulesToQuery } from '@casl/ability/extra'; - -function convertToMongoQuery(rule: AnyMongoAbility['rules'][number]) { - const conditions = rule.conditions!; - return rule.inverted ? { $nor: [conditions] } : conditions; -} - -/** - * @deprecated use accessibleBy instead - * - * Converts ability action + subjectType to MongoDB query - */ -export function toMongoQuery( - ability: T, - subjectType: Parameters[1], - action: Parameters[0] = 'read' -): AbilityQuery | null { - return rulesToQuery(ability, action, subjectType, convertToMongoQuery); -} - -export interface RecordTypes { -} -type StringOrKeysOf = keyof T extends never ? string : keyof T; - -/** - * Returns Mongo query per record type (i.e., entity type) based on provided Ability and action. - * In case action is not allowed, it returns `{ $expr: false }` - */ -export function accessibleBy( - ability: T, - action: Parameters[0] = 'read' -): Record, AbilityQuery> { - return new Proxy({ - _ability: ability, - _action: action - }, accessibleByProxyHandlers) as unknown as Record, AbilityQuery>; -} - -export const EMPTY_RESULT_QUERY = { $expr: { $eq: [0, 1] } }; -const accessibleByProxyHandlers: ProxyHandler<{ _ability: AnyMongoAbility, _action: string }> = { - get(target, subjectType) { - const query = rulesToQuery(target._ability, target._action, subjectType, convertToMongoQuery); - return query === null ? EMPTY_RESULT_QUERY : query; - } -}; diff --git a/packages/casl-mongoose/src/accessible_fields.ts b/packages/casl-mongoose/src/plugins/accessible_fields.ts similarity index 100% rename from packages/casl-mongoose/src/accessible_fields.ts rename to packages/casl-mongoose/src/plugins/accessible_fields.ts diff --git a/packages/casl-mongoose/src/accessible_records.ts b/packages/casl-mongoose/src/plugins/accessible_records.ts similarity index 64% rename from packages/casl-mongoose/src/accessible_records.ts rename to packages/casl-mongoose/src/plugins/accessible_records.ts index 1eff42e38..59de14adf 100644 --- a/packages/casl-mongoose/src/accessible_records.ts +++ b/packages/casl-mongoose/src/plugins/accessible_records.ts @@ -1,27 +1,8 @@ -import { Normalize, AnyMongoAbility, Generics, ForbiddenError } from '@casl/ability'; -import { Schema, QueryWithHelpers, Model, Document, HydratedDocument, Query } from 'mongoose'; -import { EMPTY_RESULT_QUERY, toMongoQuery } from './mongo'; +import { AnyMongoAbility, Generics, Normalize } from '@casl/ability'; +import { Document, HydratedDocument, Model, Query, QueryWithHelpers, Schema } from 'mongoose'; +import { accessibleBy } from '../accessibleBy'; -function failedQuery( - ability: AnyMongoAbility, - action: string, - modelName: string, - query: QueryWithHelpers -) { - query.where(EMPTY_RESULT_QUERY); - const anyQuery: any = query; - - if (typeof anyQuery.pre === 'function') { - anyQuery.pre((cb: (error?: Error) => void) => { - const error = ForbiddenError.from(ability).unlessCan(action, modelName); - cb(error); - }); - } - - return query; -} - -function accessibleBy( +function accessibleRecords( baseQuery: Query, ability: T, action?: Normalize['abilities']>[0] @@ -34,11 +15,7 @@ function accessibleBy( throw new TypeError(`Cannot detect subject type of "${baseQuery.model.modelName}" to return accessible records`); } - const query = toMongoQuery(ability, subjectType, action); - - if (query === null) { - return failedQuery(ability, action || 'read', subjectType, baseQuery.where()); - } + const query = accessibleBy(ability, action).ofType(subjectType); return baseQuery.and([query]); } @@ -80,7 +57,7 @@ export interface AccessibleRecordModel< } function modelAccessibleBy(this: Model, ability: AnyMongoAbility, action?: string) { - return accessibleBy(this.where(), ability, action); + return accessibleRecords(this.where(), ability, action); } function queryAccessibleBy( @@ -88,7 +65,7 @@ function queryAccessibleBy( ability: AnyMongoAbility, action?: string ) { - return accessibleBy(this, ability, action); + return accessibleRecords(this, ability, action); } export function accessibleRecordsPlugin(schema: Schema): void {